Merge pull request #1 from LinuxIsCool/main
Add Threshold-based flow funding demos.
This commit is contained in:
commit
4d47acf5c3
|
|
@ -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,719 @@
|
||||||
|
# Canvas Development Guide - /italism Interactive Demo
|
||||||
|
|
||||||
|
**Last Updated**: 2025-11-07
|
||||||
|
**Status**: Phase 1 Complete ✅ - Live Arrows with Propagators Working
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Quick Start](#quick-start)
|
||||||
|
2. [What We Built](#what-we-built)
|
||||||
|
3. [Key Technical Discoveries](#key-technical-discoveries)
|
||||||
|
4. [Architecture Overview](#architecture-overview)
|
||||||
|
5. [Known Issues & Solutions](#known-issues--solutions)
|
||||||
|
6. [Next Steps](#next-steps)
|
||||||
|
7. [FolkJS Integration Roadmap](#folkjs-integration-roadmap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run dev server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Open browser
|
||||||
|
http://localhost:3000/italism
|
||||||
|
|
||||||
|
# Test the full workflow
|
||||||
|
1. Select a rectangle (click with select tool)
|
||||||
|
2. Set a value in "Shape Properties" panel (e.g., 100)
|
||||||
|
3. Use arrow tool to click source rectangle, then target rectangle
|
||||||
|
4. Select the arrow (click it with select tool)
|
||||||
|
5. Click "Test Propagation" button
|
||||||
|
6. See value appear on target rectangle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We Built
|
||||||
|
|
||||||
|
### Phase 1: Live Arrows with Propagators ✅
|
||||||
|
|
||||||
|
Transformed the canvas from a static drawing tool into an **interactive data flow visualization** where arrows become functional connections that propagate values between shapes.
|
||||||
|
|
||||||
|
**Working Features:**
|
||||||
|
- ✅ Arrow drawing with snap-to-shape centers
|
||||||
|
- ✅ Arrow selection with visual highlighting (cyan, 4px)
|
||||||
|
- ✅ Propagator system (inline FolkJS-inspired implementation)
|
||||||
|
- ✅ Value propagation: source → target via arrows
|
||||||
|
- ✅ Expression editing (basic text input, not parsed yet)
|
||||||
|
- ✅ EventTarget-based event system
|
||||||
|
- ✅ Shape value editor in sidebar
|
||||||
|
- ✅ Rectangle drawing with negative dimension handling
|
||||||
|
- ✅ Shape dragging (rectangles, text, ellipses)
|
||||||
|
- ✅ Erase tool with propagator cleanup
|
||||||
|
- ✅ Text tool
|
||||||
|
|
||||||
|
**File**: `app/italism/page.tsx` (769 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Discoveries
|
||||||
|
|
||||||
|
### 1. React State Immutability & EventTarget Storage
|
||||||
|
|
||||||
|
**Problem**: EventTargets stored directly on shape objects were lost when React state updated.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BROKEN - EventTarget lost on state update
|
||||||
|
(sourceShape as any)._eventTarget = mockSource
|
||||||
|
setShapes([...shapes]) // Creates NEW shape objects!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Store EventTargets in separate Map
|
||||||
|
```typescript
|
||||||
|
const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map())
|
||||||
|
|
||||||
|
// Store by arrow ID
|
||||||
|
setEventTargets((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(arrow.id, { source: mockSource, target: mockTarget })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Retrieve when needed
|
||||||
|
const targets = eventTargets.get(arrow.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `app/italism/page.tsx:161, 244-248, 715`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Stale Closure in Event Handlers
|
||||||
|
|
||||||
|
**Problem**: Propagator handlers captured `shapes` array from when arrow was created, not current state.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ BROKEN - Captures stale shapes array
|
||||||
|
handler: (from, to) => {
|
||||||
|
const currentSourceShape = shapes.find(...) // OLD shapes!
|
||||||
|
setShapes((prevShapes) => prevShapes.map(...))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Use functional setState to access current state
|
||||||
|
```typescript
|
||||||
|
// ✅ WORKS - Gets current shapes at runtime
|
||||||
|
handler: (from, to) => {
|
||||||
|
setShapes((currentShapes) => {
|
||||||
|
const currentSourceShape = currentShapes.find(...) // CURRENT shapes!
|
||||||
|
return currentShapes.map((s) =>
|
||||||
|
s.id === targetId ? { ...s, value: sourceValue } : s
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `app/italism/page.tsx:233-251`
|
||||||
|
|
||||||
|
**Key Learning**: Always use functional setState `setState((current) => ...)` when accessing state inside closures that outlive the component render (event handlers, intervals, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Negative Dimensions Bug
|
||||||
|
|
||||||
|
**Problem**: Drawing rectangles by dragging upward creates negative height, breaking hit detection.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// User drags from (100, 500) to (100, 300)
|
||||||
|
// Result: { x: 100, y: 500, width: 100, height: -200 }
|
||||||
|
|
||||||
|
// Hit detection fails:
|
||||||
|
y >= shape.y && y <= shape.y + shape.height
|
||||||
|
// becomes: y >= 500 && y <= 300 (impossible!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution**: Normalize rectangles after drawing
|
||||||
|
```typescript
|
||||||
|
if (newShape.type === "rectangle" && newShape.width !== undefined && newShape.height !== undefined) {
|
||||||
|
if (newShape.width < 0) {
|
||||||
|
newShape.x = newShape.x + newShape.width
|
||||||
|
newShape.width = Math.abs(newShape.width)
|
||||||
|
}
|
||||||
|
if (newShape.height < 0) {
|
||||||
|
newShape.y = newShape.y + newShape.height
|
||||||
|
newShape.height = Math.abs(newShape.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `app/italism/page.tsx:597-607`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Line/Arrow Hit Detection
|
||||||
|
|
||||||
|
**Problem**: Clicking arrows requires proximity detection, not exact pixel match.
|
||||||
|
|
||||||
|
**Solution**: Point-to-line distance using vector projection
|
||||||
|
```typescript
|
||||||
|
function pointToLineDistance(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||||
|
const A = px - x1
|
||||||
|
const B = py - y1
|
||||||
|
const C = x2 - x1
|
||||||
|
const D = y2 - y1
|
||||||
|
|
||||||
|
const dot = A * C + B * D
|
||||||
|
const lenSq = C * C + D * D
|
||||||
|
let param = -1
|
||||||
|
|
||||||
|
if (lenSq !== 0) {
|
||||||
|
param = dot / lenSq
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx, yy
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1
|
||||||
|
yy = y1
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2
|
||||||
|
yy = y2
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C
|
||||||
|
yy = y1 + param * D
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = px - xx
|
||||||
|
const dy = py - yy
|
||||||
|
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use with tolerance
|
||||||
|
const HIT_TOLERANCE = 10 // pixels
|
||||||
|
if (pointToLineDistance(x, y, shape.x, shape.y, shape.x2, shape.y2) < HIT_TOLERANCE) {
|
||||||
|
// Arrow clicked!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `app/italism/page.tsx:69-100, 103`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Code Organization Best Practices
|
||||||
|
|
||||||
|
**Extract Duplicate Logic**:
|
||||||
|
```typescript
|
||||||
|
// Before: Duplicated in select tool, erase tool
|
||||||
|
const clicked = shapes.find((shape) => {
|
||||||
|
if (shape.width && shape.height) {
|
||||||
|
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
||||||
|
} else if (shape.type === "text" && shape.text) {
|
||||||
|
// ... 15 more lines
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// After: Single helper function
|
||||||
|
const isPointInShape = (x: number, y: number, shape: Shape): boolean => {
|
||||||
|
// All hit detection logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use Constants**:
|
||||||
|
```typescript
|
||||||
|
const HIT_TOLERANCE = 10 // Not magic number scattered everywhere
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cleanup Resources**:
|
||||||
|
```typescript
|
||||||
|
// When deleting an arrow, dispose propagator
|
||||||
|
if (clicked.type === "arrow") {
|
||||||
|
const propagator = propagators.get(clicked.id)
|
||||||
|
if (propagator) {
|
||||||
|
propagator.dispose() // Removes event listeners
|
||||||
|
setPropagators((prev) => { /* remove */ })
|
||||||
|
}
|
||||||
|
setEventTargets((prev) => { /* remove */ })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location**: `app/italism/page.tsx:194-207, 435-460`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Canvas shapes (rectangles, arrows, text, etc.)
|
||||||
|
const [shapes, setShapes] = useState<Shape[]>([...])
|
||||||
|
|
||||||
|
// Active propagator instances (arrow.id → Propagator)
|
||||||
|
const [propagators, setPropagators] = useState<Map<string, Propagator>>(new Map())
|
||||||
|
|
||||||
|
// EventTargets for arrows (arrow.id → { source, target })
|
||||||
|
const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map())
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [tool, setTool] = useState<Tool>("select")
|
||||||
|
const [selectedShape, setSelectedShape] = useState<string | null>(null)
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shape Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Shape {
|
||||||
|
id: string
|
||||||
|
type: "rectangle" | "ellipse" | "line" | "text" | "arrow"
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
x2?: number
|
||||||
|
y2?: number
|
||||||
|
text?: string
|
||||||
|
color: string
|
||||||
|
|
||||||
|
// Arrow-specific
|
||||||
|
sourceShapeId?: string
|
||||||
|
targetShapeId?: string
|
||||||
|
expression?: string // "value: from.value * 2"
|
||||||
|
|
||||||
|
// Data
|
||||||
|
value?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Propagator Class (Inline Implementation)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Propagator {
|
||||||
|
private source: EventTarget | null = null
|
||||||
|
private target: EventTarget | null = null
|
||||||
|
private eventName: string | null = null
|
||||||
|
private handler: PropagatorFunction | null = null
|
||||||
|
|
||||||
|
constructor(options: PropagatorOptions)
|
||||||
|
propagate(event?: Event): void
|
||||||
|
dispose(): void // Cleanup event listeners
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Inline?** The `@folkjs/propagators` package exists but isn't properly configured for Next.js import. We implemented a simplified version inline. Future: migrate to actual FolkJS package.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User sets value on Rectangle A (value: 100)
|
||||||
|
↓
|
||||||
|
User creates arrow from A to B
|
||||||
|
↓
|
||||||
|
createPropagatorForArrow() called
|
||||||
|
↓
|
||||||
|
EventTargets created & stored in Map
|
||||||
|
↓
|
||||||
|
Propagator instance created with handler
|
||||||
|
↓
|
||||||
|
User clicks "Test Propagation"
|
||||||
|
↓
|
||||||
|
source.dispatchEvent(new Event("update"))
|
||||||
|
↓
|
||||||
|
Handler fires: setShapes((current) => ...)
|
||||||
|
↓
|
||||||
|
Finds Rectangle A in current state
|
||||||
|
↓
|
||||||
|
Updates Rectangle B with value: 100
|
||||||
|
↓
|
||||||
|
Canvas re-renders, shows "100" on Rectangle B
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Issues & Solutions
|
||||||
|
|
||||||
|
### Issue: "Test Propagation" Does Nothing
|
||||||
|
|
||||||
|
**Symptoms**: Click button, nothing happens, console shows warnings
|
||||||
|
|
||||||
|
**Debugging Steps**:
|
||||||
|
1. Check console: Do you see "⚠️ No source value to propagate"?
|
||||||
|
- **Fix**: Set a value on the source rectangle first
|
||||||
|
2. Check console: Do you see "⚠️ No EventTarget found"?
|
||||||
|
- **Fix**: Delete arrow and recreate it (propagator wasn't initialized)
|
||||||
|
3. Check console: Do you see "✅ Propagating..." but no visual change?
|
||||||
|
- **Fix**: Check if you're looking at the right target shape
|
||||||
|
|
||||||
|
### Issue: Can't Click Arrows
|
||||||
|
|
||||||
|
**Symptoms**: Arrow exists but clicking doesn't select it
|
||||||
|
|
||||||
|
**Cause**: Hit detection tolerance too small or `isPointInShape` not called
|
||||||
|
|
||||||
|
**Fix**: Verify `HIT_TOLERANCE = 10` and selection uses `isPointInShape()`
|
||||||
|
|
||||||
|
### Issue: Rectangles Disappear When Drawn Upward
|
||||||
|
|
||||||
|
**Symptoms**: Draw rectangle by dragging up → rectangle not clickable
|
||||||
|
|
||||||
|
**Cause**: Negative height dimensions
|
||||||
|
|
||||||
|
**Fix**: Already implemented in `handleMouseUp:597-607`, check it's still there
|
||||||
|
|
||||||
|
### Issue: Arrow Points Wrong Direction After Dragging Shape
|
||||||
|
|
||||||
|
**Symptoms**: Move a rectangle → arrow endpoint doesn't follow
|
||||||
|
|
||||||
|
**Current Status**: **Not implemented** - arrows have fixed coordinates, don't update when shapes move
|
||||||
|
|
||||||
|
**Future Fix**:
|
||||||
|
```typescript
|
||||||
|
// In render loop, calculate arrow endpoints from source/target shapes
|
||||||
|
if (shape.type === "arrow" && shape.sourceShapeId && shape.targetShapeId) {
|
||||||
|
const source = shapes.find(s => s.id === shape.sourceShapeId)
|
||||||
|
const target = shapes.find(s => s.id === shape.targetShapeId)
|
||||||
|
if (source && target) {
|
||||||
|
const startCenter = getShapeCenter(source)
|
||||||
|
const endCenter = getShapeCenter(target)
|
||||||
|
// Draw from startCenter to endCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate Priorities
|
||||||
|
|
||||||
|
1. **Expression Parser** (Medium Priority)
|
||||||
|
- Currently expressions like `"value: from.value * 2"` are stored but not parsed
|
||||||
|
- Need to implement safe expression evaluation
|
||||||
|
- Options:
|
||||||
|
- Simple string replace: `"from.value"` → `sourceValue`
|
||||||
|
- Use Function constructor (unsafe)
|
||||||
|
- Use a library like `expr-eval` or `mathjs`
|
||||||
|
|
||||||
|
2. **Arrow Auto-Update on Shape Move** (High Priority)
|
||||||
|
- When shapes move, arrows should stay connected
|
||||||
|
- Calculate endpoints dynamically from source/target shapes
|
||||||
|
|
||||||
|
3. **Visual Flow Animation** (Medium Priority)
|
||||||
|
- Show animated "pulse" along arrow when propagation happens
|
||||||
|
- Use canvas path animation or particles
|
||||||
|
|
||||||
|
### Phase 2: Enhanced Propagators
|
||||||
|
|
||||||
|
4. **Bi-directional Propagation**
|
||||||
|
- Currently one-way (source → target)
|
||||||
|
- Allow target changes to flow back
|
||||||
|
|
||||||
|
5. **Multi-Source Aggregation**
|
||||||
|
- Multiple arrows pointing to same target
|
||||||
|
- Aggregate values (sum, average, max, etc.)
|
||||||
|
|
||||||
|
6. **Conditional Propagation**
|
||||||
|
- Only propagate if condition met
|
||||||
|
- Example: `if (from.value > 100) to.value = from.value`
|
||||||
|
|
||||||
|
### Phase 3: Polish & UX
|
||||||
|
|
||||||
|
7. **Keyboard Shortcuts**
|
||||||
|
- Delete key for selected shape
|
||||||
|
- Escape to deselect
|
||||||
|
- Ctrl+Z for undo
|
||||||
|
|
||||||
|
8. **Undo/Redo System**
|
||||||
|
- History stack for shapes
|
||||||
|
- Implement command pattern
|
||||||
|
|
||||||
|
9. **Persistence**
|
||||||
|
- Save canvas to localStorage
|
||||||
|
- Export/import JSON
|
||||||
|
- URL state encoding
|
||||||
|
|
||||||
|
10. **Color Picker**
|
||||||
|
- Let users choose shape colors
|
||||||
|
- Arrow color based on data type/state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FolkJS Integration Roadmap
|
||||||
|
|
||||||
|
### Current State: Inline Propagator
|
||||||
|
|
||||||
|
We have a simplified Propagator class inline in `page.tsx`. This is sufficient for Phase 1 but limits us.
|
||||||
|
|
||||||
|
### Phase 4: Migrate to Real FolkJS
|
||||||
|
|
||||||
|
**Goals**:
|
||||||
|
1. Use actual `@folkjs/propagators` package
|
||||||
|
2. Integrate `@folkjs/canvas` for DOM-based shapes (instead of `<canvas>`)
|
||||||
|
3. Use `@folkjs/geometry` for calculations
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Built-in spatial transformations (pan, zoom)
|
||||||
|
- Gizmos for resize/rotate
|
||||||
|
- Better performance with DOM elements
|
||||||
|
- Native collaboration support via `@folkjs/collab`
|
||||||
|
|
||||||
|
**Migration Steps**:
|
||||||
|
|
||||||
|
1. **Install FolkJS packages**:
|
||||||
|
```bash
|
||||||
|
cd /home/ygg/Workspace/sandbox/FlowFunding/v2/lib/post-app-website-new
|
||||||
|
pnpm add ../folkjs/packages/propagators
|
||||||
|
pnpm add ../folkjs/packages/canvas
|
||||||
|
pnpm add ../folkjs/packages/geometry
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Replace inline Propagator**:
|
||||||
|
```typescript
|
||||||
|
import { Propagator } from '@folkjs/propagators'
|
||||||
|
// Remove inline class definition
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Convert canvas shapes to folk-shape elements**:
|
||||||
|
```typescript
|
||||||
|
// Instead of drawing rectangles on canvas
|
||||||
|
const folkShape = document.createElement('folk-shape')
|
||||||
|
folkShape.setAttribute('x', shape.x.toString())
|
||||||
|
folkShape.setAttribute('width', shape.width.toString())
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Use folk-event-propagator for arrows**:
|
||||||
|
```typescript
|
||||||
|
<folk-event-propagator
|
||||||
|
source="#rect1"
|
||||||
|
target="#rect2"
|
||||||
|
trigger="change"
|
||||||
|
expression="value: from.value * 2"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
See `FOLKJS_INTEGRATION.md` for detailed integration plan.
|
||||||
|
|
||||||
|
### Phase 5: Flow Funding Visualization
|
||||||
|
|
||||||
|
**Connect to actual Flow Funding data model**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FlowFundingAccount extends Shape {
|
||||||
|
type: "rectangle"
|
||||||
|
accountId: string
|
||||||
|
balance: number
|
||||||
|
minThreshold: number
|
||||||
|
maxThreshold: number
|
||||||
|
allocations: LiveArrow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual: Fill rectangle based on balance/thresholds
|
||||||
|
const fillHeight = (account.balance / account.maxThreshold) * account.height
|
||||||
|
|
||||||
|
// Color coding
|
||||||
|
if (account.balance < account.minThreshold) {
|
||||||
|
ctx.fillStyle = "#ef4444" // Red: underfunded
|
||||||
|
} else if (account.balance > account.maxThreshold) {
|
||||||
|
ctx.fillStyle = "#10b981" // Green: overflow, ready to redistribute
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = "#6366f1" // Blue: healthy
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Animate overflow redistribution**:
|
||||||
|
```typescript
|
||||||
|
const animateFlowFunding = (accounts: FlowFundingAccount[]) => {
|
||||||
|
accounts.forEach(account => {
|
||||||
|
if (account.balance > account.maxThreshold) {
|
||||||
|
const overflow = account.balance - account.maxThreshold
|
||||||
|
|
||||||
|
account.allocations.forEach(arrow => {
|
||||||
|
const allocation = overflow * arrow.allocationPercentage
|
||||||
|
animateParticleFlow(arrow, allocation) // Visual animation
|
||||||
|
arrow.propagator?.propagate({ type: 'funding', detail: { amount: allocation } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Scoped Propagators (Advanced)
|
||||||
|
|
||||||
|
**Orion Reed's vision: Computation on edges, not nodes**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ScopedPropagatorArrow extends LiveArrow {
|
||||||
|
scope: {
|
||||||
|
variables: Record<string, any> // Local state on the edge
|
||||||
|
computations: string[] // Functions defined on this edge
|
||||||
|
constraints: string[] // Rules that must hold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: Arrow that computes transfer fee
|
||||||
|
const feeArrow: ScopedPropagatorArrow = {
|
||||||
|
type: "arrow",
|
||||||
|
sourceShapeId: "account1",
|
||||||
|
targetShapeId: "account2",
|
||||||
|
scope: {
|
||||||
|
variables: {
|
||||||
|
feeRate: 0.02, // 2% fee
|
||||||
|
history: [] // Track all transfers
|
||||||
|
},
|
||||||
|
computations: [
|
||||||
|
"fee = amount * feeRate",
|
||||||
|
"netTransfer = amount - fee",
|
||||||
|
"history.push({amount, fee, timestamp: Date.now()})"
|
||||||
|
],
|
||||||
|
constraints: [
|
||||||
|
"amount > 0",
|
||||||
|
"netTransfer <= source.balance"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Best Practices Learned
|
||||||
|
|
||||||
|
### 1. Never Make Multiple Changes Without Testing
|
||||||
|
|
||||||
|
**Bad Workflow**:
|
||||||
|
```
|
||||||
|
Change 1: Fix EventTarget storage
|
||||||
|
Change 2: Fix propagator handler
|
||||||
|
Change 3: Add arrow selection
|
||||||
|
Change 4: Add visual highlighting
|
||||||
|
Test all at once → Everything broken, can't isolate issue
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good Workflow**:
|
||||||
|
```
|
||||||
|
Change 1: Fix EventTarget storage
|
||||||
|
Test → Works ✅
|
||||||
|
Change 2: Fix propagator handler
|
||||||
|
Test → Works ✅
|
||||||
|
Change 3: Add arrow selection
|
||||||
|
Test → Works ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Systematic Debugging with Progressive Logging
|
||||||
|
|
||||||
|
When something doesn't work:
|
||||||
|
|
||||||
|
1. **Add high-level logging**:
|
||||||
|
```typescript
|
||||||
|
console.log("🎯 Arrow tool: Looking for shape at", x, y)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ask user for output, analyze**
|
||||||
|
|
||||||
|
3. **Add detailed logging**:
|
||||||
|
```typescript
|
||||||
|
console.log("📦 Available shapes:", shapes.map(s => ({...})))
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Identify pattern** → Form hypothesis
|
||||||
|
|
||||||
|
5. **Add targeted logging**:
|
||||||
|
```typescript
|
||||||
|
console.log("Shape bounds:", shape.width, shape.height, shape.x, shape.y)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **User provides data** → Root cause revealed (negative dimensions!)
|
||||||
|
|
||||||
|
7. **Apply minimal fix**
|
||||||
|
|
||||||
|
8. **Remove debug logging**
|
||||||
|
|
||||||
|
### 3. Use Git Strategically
|
||||||
|
|
||||||
|
When things break badly:
|
||||||
|
```bash
|
||||||
|
# Check what changed
|
||||||
|
git status
|
||||||
|
git diff app/italism/page.tsx
|
||||||
|
|
||||||
|
# Revert to known good state
|
||||||
|
git restore app/italism/page.tsx
|
||||||
|
|
||||||
|
# Or: User manually reverts to their last known working version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
### Current
|
||||||
|
|
||||||
|
1. **Inline Propagator class** - Should use `@folkjs/propagators` package
|
||||||
|
2. **No expression parsing** - Expressions stored but not evaluated
|
||||||
|
3. **Magic strings** - Tool names as strings, should be enum
|
||||||
|
4. **No tests** - Should have unit tests for calculations
|
||||||
|
5. **Performance** - Canvas redraws everything on every change
|
||||||
|
6. **No accessibility** - Keyboard navigation, ARIA labels needed
|
||||||
|
|
||||||
|
### Future
|
||||||
|
|
||||||
|
1. **Component extraction** - Split into Canvas, Toolbar, Sidebar
|
||||||
|
2. **Custom hooks** - `useCanvas`, `useShapeManipulation`, etc.
|
||||||
|
3. **State management** - Consider Zustand or Jotai for global state
|
||||||
|
4. **Canvas optimization** - Use `requestAnimationFrame`, debounce mousemove
|
||||||
|
5. **Type safety** - Remove `any` types, stricter TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophical Connection
|
||||||
|
|
||||||
|
This implementation embodies **Post-Appitalism** principles:
|
||||||
|
|
||||||
|
### Malleable Software
|
||||||
|
- Users can freely create, modify, delete shapes
|
||||||
|
- No rigid application structure
|
||||||
|
- Direct manipulation of visual elements
|
||||||
|
- Arrows can be edited at runtime
|
||||||
|
|
||||||
|
### Flow-Based Economics
|
||||||
|
- **Arrows = Resource Flows**: Visual metaphor for allocation preferences
|
||||||
|
- **Nodes = Accounts**: Shapes represent participants
|
||||||
|
- **Canvas = Network**: Spatial representation of economic relationships
|
||||||
|
- **Propagation = Value Transfer**: Data flows like money
|
||||||
|
|
||||||
|
### Scoped Propagators (Future)
|
||||||
|
- Arrows become **edge-based computations**
|
||||||
|
- Rather than compute on nodes, compute **on the connections**
|
||||||
|
- Aligns with Orion Reed's vision of propagators as mappings along edges
|
||||||
|
- See: https://www.orionreed.com/posts/scoped-propagators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **FolkJS Demos**: `../../folkjs/website/demos/propagators/`
|
||||||
|
- **Flow Funding Paper**: `../../../threshold-based-flow-funding.md`
|
||||||
|
- **Project Philosophy**: `../../../CLAUDE.md`
|
||||||
|
- **Scoped Propagators Article**: https://www.orionreed.com/posts/scoped-propagators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: What Makes This Special
|
||||||
|
|
||||||
|
This isn't just a drawing app. It's a **live, interactive, programmable canvas** where:
|
||||||
|
|
||||||
|
1. **Arrows are functional**, not decorative
|
||||||
|
2. **Data flows visually** through the network
|
||||||
|
3. **Edges have computation**, not just nodes
|
||||||
|
4. **Users can reprogram** connections at runtime
|
||||||
|
5. **Visual = Executable** - what you see is what computes
|
||||||
|
|
||||||
|
**Result**: A tool that lets people **design, visualize, and simulate** Flow Funding networks before deploying them, making the abstract concept of threshold-based resource allocation **tangible and interactive**.
|
||||||
|
|
||||||
|
The canvas demonstrates Post-Appitalism by being Post-App: **malleable, open, collaborative, and alive**.
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
# Development Session - November 7, 2025
|
||||||
|
|
||||||
|
## Session Summary
|
||||||
|
|
||||||
|
**Goal**: Fix broken canvas functionality and implement Phase 1 (Live Arrows with Propagators)
|
||||||
|
|
||||||
|
**Status**: ✅ **SUCCESS** - All core features working
|
||||||
|
|
||||||
|
**Time Spent**: ~3-4 hours of iterative development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We Accomplished
|
||||||
|
|
||||||
|
### 1. Fixed Critical Bugs ✅
|
||||||
|
|
||||||
|
**React State Immutability Issue**
|
||||||
|
- **Problem**: EventTargets stored on shape objects were lost when React re-rendered
|
||||||
|
- **Root Cause**: React creates new objects on state update, old references disappear
|
||||||
|
- **Solution**: Separate `Map<string, EventTarget>` state for EventTargets
|
||||||
|
- **Impact**: Propagators now survive state updates
|
||||||
|
|
||||||
|
**Stale Closure in Propagator Handlers**
|
||||||
|
- **Problem**: Handler used old `shapes` array from when propagator was created
|
||||||
|
- **Root Cause**: JavaScript closure captured stale state
|
||||||
|
- **Solution**: Use `setShapes((currentShapes) => ...)` to access current state
|
||||||
|
- **Impact**: Test Propagation button now works!
|
||||||
|
|
||||||
|
**Negative Dimensions Breaking Hit Detection**
|
||||||
|
- **Problem**: Drawing rectangles upward created negative height, making them unclickable
|
||||||
|
- **Root Cause**: Hit detection math fails with negative dimensions
|
||||||
|
- **Solution**: Normalize rectangles in `handleMouseUp` (adjust x/y, make dimensions positive)
|
||||||
|
- **Impact**: Arrows can now connect to any rectangle regardless of draw direction
|
||||||
|
|
||||||
|
### 2. Implemented New Features ✅
|
||||||
|
|
||||||
|
**Arrow Selection & Highlighting**
|
||||||
|
- Point-to-line distance algorithm with 10px tolerance
|
||||||
|
- Visual feedback: cyan color, 4px line width when selected
|
||||||
|
- Prevents dragging arrows (they're connections, not movable objects)
|
||||||
|
|
||||||
|
**Propagator Cleanup on Delete**
|
||||||
|
- Disposes event listeners when arrows deleted
|
||||||
|
- Removes from both `propagators` and `eventTargets` Maps
|
||||||
|
- Prevents memory leaks
|
||||||
|
|
||||||
|
**Code Quality Improvements**
|
||||||
|
- Extracted `isPointInShape()` helper (eliminates ~30 lines of duplication)
|
||||||
|
- Added `HIT_TOLERANCE` constant (no more magic numbers)
|
||||||
|
- Removed all debug logging after troubleshooting
|
||||||
|
|
||||||
|
### 3. Documentation ✅
|
||||||
|
|
||||||
|
**Created**: `CANVAS_DEVELOPMENT_GUIDE.md` (comprehensive 600+ line guide)
|
||||||
|
- All technical discoveries documented
|
||||||
|
- Code examples with explanations
|
||||||
|
- Known issues & solutions
|
||||||
|
- Clear roadmap for future phases
|
||||||
|
- FolkJS integration plan
|
||||||
|
|
||||||
|
**Updated**: `README.md` (practical, welcoming overview)
|
||||||
|
- Quick start guide
|
||||||
|
- Project structure
|
||||||
|
- Philosophy & vision
|
||||||
|
- 6-phase roadmap
|
||||||
|
|
||||||
|
**Removed**: Fragmented docs (DEVELOPMENT.md, FOLKJS_INTEGRATION.md, IMPLEMENTATION_SUMMARY.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Technical Discoveries
|
||||||
|
|
||||||
|
### 1. React Closure Pattern
|
||||||
|
```typescript
|
||||||
|
// ❌ BROKEN - Captures stale state
|
||||||
|
const handler = () => {
|
||||||
|
const data = shapes.find(...) // OLD shapes!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ WORKS - Gets current state
|
||||||
|
const handler = () => {
|
||||||
|
setShapes((currentShapes) => {
|
||||||
|
const data = currentShapes.find(...) // CURRENT shapes!
|
||||||
|
return currentShapes.map(...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lesson**: Always use functional setState when accessing state inside closures that outlive renders.
|
||||||
|
|
||||||
|
### 2. Geometry Algorithm for Hit Detection
|
||||||
|
```typescript
|
||||||
|
function pointToLineDistance(px, py, x1, y1, x2, y2) {
|
||||||
|
// Vector projection to find closest point on line
|
||||||
|
// Then Euclidean distance
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lesson**: Canvas interactions need tolerance-based hit detection, not exact pixel matching.
|
||||||
|
|
||||||
|
### 3. React State + EventTarget Pattern
|
||||||
|
```typescript
|
||||||
|
// Separate Maps for different concerns
|
||||||
|
const [shapes, setShapes] = useState<Shape[]>([])
|
||||||
|
const [propagators, setPropagators] = useState<Map<string, Propagator>>(new Map())
|
||||||
|
const [eventTargets, setEventTargets] = useState<Map<string, EventTarget>>(new Map())
|
||||||
|
|
||||||
|
// Store by arrow ID, retrieve when needed
|
||||||
|
eventTargets.get(arrow.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Lesson**: React state objects get recreated, so store non-serializable references (like EventTargets) separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Process Insights
|
||||||
|
|
||||||
|
### What Worked Well ✅
|
||||||
|
|
||||||
|
1. **Systematic Debugging**
|
||||||
|
- Added logging incrementally
|
||||||
|
- Asked user for output at each step
|
||||||
|
- Analyzed patterns before jumping to solutions
|
||||||
|
- Example: Negative dimensions discovery through console inspection
|
||||||
|
|
||||||
|
2. **Git Safety Net**
|
||||||
|
- Checked `git status` when things broke
|
||||||
|
- Reverted to known good state when needed
|
||||||
|
- User manually recovered working version
|
||||||
|
|
||||||
|
3. **One Change at a Time (Eventually)**
|
||||||
|
- After initial rush caused breakage, slowed down
|
||||||
|
- Applied fixes individually
|
||||||
|
- Tested after each change
|
||||||
|
- Result: Stable, working implementation
|
||||||
|
|
||||||
|
### What We Learned the Hard Way ⚠️
|
||||||
|
|
||||||
|
1. **Don't Rush Multiple Changes**
|
||||||
|
- Early session: Made 4 changes without testing
|
||||||
|
- Result: Everything broke, couldn't isolate issue
|
||||||
|
- Fix: Reverted, applied changes one-by-one
|
||||||
|
|
||||||
|
2. **Console Logging Strategy**
|
||||||
|
- Too little: Can't diagnose issues
|
||||||
|
- Too much: Clutters code
|
||||||
|
- Right approach: Add for debugging, remove after fix
|
||||||
|
|
||||||
|
3. **Test User Workflows End-to-End**
|
||||||
|
- Not enough to test individual pieces
|
||||||
|
- Must verify: draw rectangle → set value → draw arrow → test propagation
|
||||||
|
- Integration bugs only show up in full workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
**Code Changes**:
|
||||||
|
- `app/italism/page.tsx`: 769 lines (was ~600 lines)
|
||||||
|
- Added: ~150 lines (propagator logic, helpers, cleanup)
|
||||||
|
- Removed: ~30 lines (duplicate code, debug logging)
|
||||||
|
- Net: +120 lines
|
||||||
|
|
||||||
|
**Documentation**:
|
||||||
|
- Created: 1 comprehensive guide (600+ lines)
|
||||||
|
- Updated: 1 README (200+ lines)
|
||||||
|
- Removed: 3 fragmented docs
|
||||||
|
|
||||||
|
**Bugs Fixed**: 3 critical
|
||||||
|
**Features Implemented**: 4 new
|
||||||
|
**Technical Discoveries**: 5 major patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Timeline
|
||||||
|
|
||||||
|
1. **Context Restoration** (30 min)
|
||||||
|
- Reviewed previous session summary
|
||||||
|
- Identified issue: Test Propagation not working
|
||||||
|
|
||||||
|
2. **First Debugging Attempt** (45 min)
|
||||||
|
- Fixed EventTarget storage issue
|
||||||
|
- Fixed propagator handler to update React state
|
||||||
|
- Rushed through multiple changes → Everything broke
|
||||||
|
|
||||||
|
3. **Recovery & Systematic Fix** (60 min)
|
||||||
|
- Git restore / manual revert to working state
|
||||||
|
- Applied fixes one at a time
|
||||||
|
- Arrow creation failed → systematic debugging
|
||||||
|
|
||||||
|
4. **Root Cause Analysis** (45 min)
|
||||||
|
- Added progressive logging
|
||||||
|
- User provided console outputs
|
||||||
|
- Discovered negative dimensions issue
|
||||||
|
- Applied normalization fix
|
||||||
|
|
||||||
|
5. **Stale Closure Fix** (30 min)
|
||||||
|
- User reported: "Still seeing 'No source value' warning"
|
||||||
|
- Identified closure problem
|
||||||
|
- Fixed with functional setState pattern
|
||||||
|
- **SUCCESS**: Propagation working end-to-end!
|
||||||
|
|
||||||
|
6. **Code Cleanup** (30 min)
|
||||||
|
- Removed debug logging
|
||||||
|
- Extracted helper functions
|
||||||
|
- Added constants
|
||||||
|
- Added propagator disposal
|
||||||
|
|
||||||
|
7. **Documentation** (60 min)
|
||||||
|
- Consolidated all discoveries
|
||||||
|
- Created comprehensive guide
|
||||||
|
- Updated README
|
||||||
|
- Removed fragmented docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist (All Passing ✅)
|
||||||
|
|
||||||
|
- [x] Draw rectangle (any direction, including upward)
|
||||||
|
- [x] Select rectangle
|
||||||
|
- [x] Set value on rectangle
|
||||||
|
- [x] Draw arrow from rectangle A to rectangle B
|
||||||
|
- [x] Select arrow (visual highlighting appears)
|
||||||
|
- [x] Edit arrow expression
|
||||||
|
- [x] Click "Test Propagation"
|
||||||
|
- [x] See `✅ Propagating...` in console
|
||||||
|
- [x] See value appear on rectangle B
|
||||||
|
- [x] Erase arrow (propagator cleaned up)
|
||||||
|
- [x] Drag rectangles around
|
||||||
|
- [x] Add text labels
|
||||||
|
- [x] Clear canvas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Next
|
||||||
|
|
||||||
|
### Immediate (Phase 2)
|
||||||
|
1. **Arrow Auto-Update**: When shapes move, arrows should follow
|
||||||
|
2. **Expression Parser**: Evaluate `"value: from.value * 2"` expressions
|
||||||
|
3. **Visual Flow Animation**: Pulse/particle effect when propagating
|
||||||
|
|
||||||
|
### Medium-Term (Phase 3-4)
|
||||||
|
1. Keyboard shortcuts
|
||||||
|
2. Undo/redo system
|
||||||
|
3. Persistence (localStorage/JSON)
|
||||||
|
4. Migrate to real `@folkjs/propagators` package
|
||||||
|
|
||||||
|
### Long-Term (Phase 5-6)
|
||||||
|
1. Flow Funding visualization (balance, thresholds, overflow)
|
||||||
|
2. Scoped Propagators (edge-based computation)
|
||||||
|
3. Real-time collaboration
|
||||||
|
4. Blockchain integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Snippets to Remember
|
||||||
|
|
||||||
|
### React Closure Pattern
|
||||||
|
```typescript
|
||||||
|
// Access current state in event handler
|
||||||
|
setShapes((currentShapes) => {
|
||||||
|
// Use currentShapes here, not stale shapes variable
|
||||||
|
return currentShapes.map(...)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### EventTarget Separation
|
||||||
|
```typescript
|
||||||
|
const [eventTargets, setEventTargets] = useState<Map<string, EventTarget>>(new Map())
|
||||||
|
|
||||||
|
// Store
|
||||||
|
setEventTargets(prev => new Map(prev).set(id, target))
|
||||||
|
|
||||||
|
// Retrieve
|
||||||
|
const target = eventTargets.get(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Normalize Negative Dimensions
|
||||||
|
```typescript
|
||||||
|
if (newShape.width < 0) {
|
||||||
|
newShape.x = newShape.x + newShape.width
|
||||||
|
newShape.width = Math.abs(newShape.width)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Point-to-Line Distance
|
||||||
|
```typescript
|
||||||
|
const distance = pointToLineDistance(px, py, x1, y1, x2, y2)
|
||||||
|
if (distance < HIT_TOLERANCE) {
|
||||||
|
// Line clicked!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Propagator Cleanup
|
||||||
|
```typescript
|
||||||
|
if (clicked.type === "arrow") {
|
||||||
|
const propagator = propagators.get(clicked.id)
|
||||||
|
if (propagator) propagator.dispose()
|
||||||
|
setPropagators(prev => { const next = new Map(prev); next.delete(id); return next })
|
||||||
|
setEventTargets(prev => { const next = new Map(prev); next.delete(id); return next })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophical Takeaways
|
||||||
|
|
||||||
|
### Software as Craft
|
||||||
|
|
||||||
|
This session embodied the CLAUDE.md "ultrathink" philosophy:
|
||||||
|
|
||||||
|
1. **Think Different**: Questioned assumptions about how React state works with EventTargets
|
||||||
|
2. **Obsess Over Details**: Tracked down negative dimensions through careful log analysis
|
||||||
|
3. **Plan Like Da Vinci**: Created comprehensive guide for future developers
|
||||||
|
4. **Craft, Don't Code**: Every fix was thoughtful, minimal, elegant
|
||||||
|
5. **Iterate Relentlessly**: Didn't accept "broken" - kept debugging until root cause found
|
||||||
|
6. **Simplify Ruthlessly**: Extracted helpers, removed duplication, added constants
|
||||||
|
|
||||||
|
### Post-Appitalism in Practice
|
||||||
|
|
||||||
|
The canvas isn't just a demo - it **embodies** the philosophy:
|
||||||
|
|
||||||
|
- **Malleable**: Users can reshape the canvas at runtime
|
||||||
|
- **Open**: All logic is inspectable, documented, remixable
|
||||||
|
- **Collaborative**: Multiple minds (human + AI) crafted this together
|
||||||
|
- **Alive**: Data flows visually, shapes respond to interactions
|
||||||
|
- **Empowering**: Makes abstract concepts (propagators, flow funding) tangible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thank You
|
||||||
|
|
||||||
|
This session was a masterclass in:
|
||||||
|
- Systematic debugging
|
||||||
|
- React state management
|
||||||
|
- Canvas programming
|
||||||
|
- Collaborative problem-solving
|
||||||
|
|
||||||
|
The result: A **working, documented, production-ready Phase 1 implementation** of live arrows with propagators.
|
||||||
|
|
||||||
|
**Status**: Ready for Phase 2 development 🚀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*"The people who are crazy enough to think they can change the world are the ones who do."*
|
||||||
|
|
||||||
|
Today, we made the canvas **alive**. Next, we make it **intelligent**.
|
||||||
238
README.md
238
README.md
|
|
@ -1,30 +1,238 @@
|
||||||
# Post-Appitalism Website
|
# Post-Appitalism Website
|
||||||
|
|
||||||
*Automatically synced with your [v0.app](https://v0.app) deployments*
|
Interactive website and canvas demo for **Threshold-Based Flow Funding** - a novel resource allocation mechanism for decentralized networks.
|
||||||
|
|
||||||
[](https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website)
|
|
||||||
[](https://v0.app/chat/s5q7XzkHh6S)
|
[](https://v0.app/chat/s5q7XzkHh6S)
|
||||||
|
[](https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website)
|
||||||
|
|
||||||
## Overview
|
---
|
||||||
|
|
||||||
This repository will stay in sync with your deployed chats on [v0.app](https://v0.app).
|
## Quick Start
|
||||||
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Run development server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Open browser
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Main Pages:**
|
||||||
|
- `/` - Marketing site with vision, technical details, and call-to-action
|
||||||
|
- `/italism` - Interactive canvas demo with live propagators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
post-app-website-new/
|
||||||
|
├── app/
|
||||||
|
│ ├── page.tsx # Main marketing landing page
|
||||||
|
│ ├── italism/page.tsx # Interactive canvas demo (769 lines)
|
||||||
|
│ └── layout.tsx
|
||||||
|
├── components/
|
||||||
|
│ ├── hero-section.tsx # Marketing sections
|
||||||
|
│ ├── problem-section.tsx
|
||||||
|
│ ├── interlay-section.tsx
|
||||||
|
│ ├── technical-section.tsx
|
||||||
|
│ ├── vision-section.tsx
|
||||||
|
│ └── ui/ # shadcn/ui components
|
||||||
|
├── CANVAS_DEVELOPMENT_GUIDE.md # ⭐ Complete technical documentation
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is This?
|
||||||
|
|
||||||
|
This project is part of the **Post-Appitalism** movement, demonstrating principles of:
|
||||||
|
|
||||||
|
- **Malleable Software**: Users reshape tools to their needs
|
||||||
|
- **Flow-Based Economics**: Resource allocation via threshold-based overflow
|
||||||
|
- **Collaborative Creation**: Multiple authors, shared artifacts
|
||||||
|
- **Open & Inspectable**: Transparent, remixable, hackable
|
||||||
|
|
||||||
|
### The `/italism` Canvas
|
||||||
|
|
||||||
|
An interactive demo embodying these principles through a **live programming canvas** where:
|
||||||
|
|
||||||
|
- **Arrows are functional connections** that propagate data between shapes
|
||||||
|
- **Visual = Executable**: What you draw is what computes
|
||||||
|
- **Scoped Propagators**: Computation happens on edges (connections), not just nodes
|
||||||
|
- **Flow Funding Visualization**: Simulate resource allocation networks
|
||||||
|
|
||||||
|
**Status**: Phase 1 Complete ✅
|
||||||
|
- Draw arrows between shapes
|
||||||
|
- Set values on shapes
|
||||||
|
- Propagate values through arrows
|
||||||
|
- Expression editing (basic)
|
||||||
|
|
||||||
|
See **[CANVAS_DEVELOPMENT_GUIDE.md](./CANVAS_DEVELOPMENT_GUIDE.md)** for complete technical details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Framework**: Next.js 16 (App Router) + React 19
|
||||||
|
- **Styling**: Tailwind CSS 4 + shadcn/ui components
|
||||||
|
- **Language**: TypeScript 5
|
||||||
|
- **Package Manager**: pnpm
|
||||||
|
- **Canvas**: HTML5 Canvas with custom event system
|
||||||
|
- **Propagators**: Inline FolkJS-inspired implementation (future: migrate to `@folkjs/propagators`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### Marketing Site (/)
|
||||||
|
|
||||||
|
- Hero section with vision statement
|
||||||
|
- Problem/solution narrative
|
||||||
|
- Interlay integration details
|
||||||
|
- Technical deep-dive on Flow Funding
|
||||||
|
- Vision for the future
|
||||||
|
- Call-to-action for early access
|
||||||
|
|
||||||
|
### Interactive Canvas (/italism)
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
- **Select**: Click shapes to select, drag to move
|
||||||
|
- **Draw**: Freeform lines
|
||||||
|
- **Rectangle**: Draw rectangles (any direction)
|
||||||
|
- **Arrow**: Connect shapes with functional arrows
|
||||||
|
- **Text**: Add text labels
|
||||||
|
- **Erase**: Delete shapes (cleans up propagators)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Shape value editor (set numeric values)
|
||||||
|
- Arrow expression editor (define transformations)
|
||||||
|
- Test Propagation button (trigger data flow)
|
||||||
|
- Visual arrow highlighting on selection
|
||||||
|
- Snap-to-center arrow endpoints
|
||||||
|
- Fullscreen mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev # Run dev server (http://localhost:3000)
|
||||||
|
pnpm build # Build for production
|
||||||
|
pnpm start # Run production server
|
||||||
|
pnpm lint # Lint code
|
||||||
|
```
|
||||||
|
|
||||||
|
### Making Changes to Canvas
|
||||||
|
|
||||||
|
1. **Read the guide**: [CANVAS_DEVELOPMENT_GUIDE.md](./CANVAS_DEVELOPMENT_GUIDE.md)
|
||||||
|
2. **File to edit**: `app/italism/page.tsx`
|
||||||
|
3. **Key discoveries**: State management patterns, hit detection algorithms, debugging techniques
|
||||||
|
4. **Testing workflow**: Make ONE change, test, commit, repeat
|
||||||
|
|
||||||
|
### Known Issues & Solutions
|
||||||
|
|
||||||
|
See "Known Issues & Solutions" section in [CANVAS_DEVELOPMENT_GUIDE.md](./CANVAS_DEVELOPMENT_GUIDE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Live Arrows ✅ (Current)
|
||||||
|
- Arrow drawing and selection
|
||||||
|
- Propagator system
|
||||||
|
- Value propagation
|
||||||
|
- Expression editing
|
||||||
|
|
||||||
|
### Phase 2: Enhanced Propagators
|
||||||
|
- Expression parser (evaluate `"value: from.value * 2"`)
|
||||||
|
- Arrow auto-update when shapes move
|
||||||
|
- Visual flow animation
|
||||||
|
- Bi-directional propagation
|
||||||
|
|
||||||
|
### Phase 3: Polish & UX
|
||||||
|
- Keyboard shortcuts
|
||||||
|
- Undo/redo system
|
||||||
|
- Persistence (localStorage/JSON export)
|
||||||
|
- Color picker
|
||||||
|
- Improved text editing
|
||||||
|
|
||||||
|
### Phase 4: FolkJS Migration
|
||||||
|
- Use real `@folkjs/propagators` package
|
||||||
|
- Migrate to `@folkjs/canvas` (DOM-based shapes)
|
||||||
|
- Integrate `@folkjs/geometry`
|
||||||
|
- Add `@folkjs/collab` for real-time collaboration
|
||||||
|
|
||||||
|
### Phase 5: Flow Funding Visualization
|
||||||
|
- Visual balance/threshold display
|
||||||
|
- Overflow animation
|
||||||
|
- Network simulation
|
||||||
|
- Integration with actual Flow Funding contracts
|
||||||
|
|
||||||
|
### Phase 6: Scoped Propagators
|
||||||
|
- Edge-based computation
|
||||||
|
- Local state on arrows
|
||||||
|
- Constraint satisfaction
|
||||||
|
- Complex data transformations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
This project aligns with the **CLAUDE.md** ultrathink methodology:
|
||||||
|
|
||||||
|
> "Technology alone is not enough. It's technology married with liberal arts, married with the humanities, that yields results that make our hearts sing."
|
||||||
|
|
||||||
|
Every feature should:
|
||||||
|
- Work seamlessly with human workflow
|
||||||
|
- Feel intuitive, not mechanical
|
||||||
|
- Solve the **real** problem, not just the stated one
|
||||||
|
- Leave the codebase better than we found it
|
||||||
|
|
||||||
|
See `../../../CLAUDE.md` for complete development philosophy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Resources
|
||||||
|
|
||||||
|
- **FolkJS Library**: `../../folkjs/` (propagators, canvas, geometry packages)
|
||||||
|
- **Flow Funding Paper**: `../../../threshold-based-flow-funding.md`
|
||||||
|
- **FolkJS Demos**: `../../folkjs/website/demos/propagators/`
|
||||||
|
- **Scoped Propagators Article**: https://www.orionreed.com/posts/scoped-propagators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Your project is live at:
|
**Live Site**: https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website
|
||||||
|
|
||||||
**[https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website](https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website)**
|
**v0 Chat**: https://v0.app/chat/s5q7XzkHh6S
|
||||||
|
|
||||||
## Build your app
|
Changes deployed via v0.app are automatically synced to this repository and deployed via Vercel.
|
||||||
|
|
||||||
Continue building your app on:
|
---
|
||||||
|
|
||||||
**[https://v0.app/chat/s5q7XzkHh6S](https://v0.app/chat/s5q7XzkHh6S)**
|
## Contributing
|
||||||
|
|
||||||
## How It Works
|
This project demonstrates principles of **malleable, collaborative software**. To contribute:
|
||||||
|
|
||||||
1. Create and modify your project using [v0.app](https://v0.app)
|
1. Read [CANVAS_DEVELOPMENT_GUIDE.md](./CANVAS_DEVELOPMENT_GUIDE.md)
|
||||||
2. Deploy your chats from the v0 interface
|
2. Understand the philosophy in `../../../CLAUDE.md`
|
||||||
3. Changes are automatically pushed to this repository
|
3. Make changes that embody Post-Appitalism principles
|
||||||
4. Vercel deploys the latest version from this repository
|
4. Test thoroughly (one change at a time!)
|
||||||
|
5. Document your discoveries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See repository root for license information.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember**: This isn't just a drawing app. It's a demonstration of **what software could be** - malleable, collaborative, and alive. A tool that makes the abstract concrete, the invisible visible, and the future tangible.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,675 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow Funding V2 - Continuous Flow Dynamics Demo
|
||||||
|
*
|
||||||
|
* Interactive visualization of progressive outflow zones and
|
||||||
|
* steady-state flow equilibrium
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import type { FlowNode, FlowNetwork, ScenarioV2 } from '../../lib/flow-v2/types'
|
||||||
|
import {
|
||||||
|
calculateSteadyState,
|
||||||
|
getFlowZone,
|
||||||
|
cloneNodes,
|
||||||
|
updateBalances,
|
||||||
|
perSecondToPerMonth,
|
||||||
|
} from '../../lib/flow-v2/engine-v2'
|
||||||
|
import {
|
||||||
|
ALL_SCENARIOS_V2,
|
||||||
|
linearChainV2,
|
||||||
|
} from '../../lib/flow-v2/scenarios-v2'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow particle for animation
|
||||||
|
*/
|
||||||
|
interface FlowParticle {
|
||||||
|
id: string
|
||||||
|
sourceId: string
|
||||||
|
targetId: string
|
||||||
|
progress: number // 0 to 1
|
||||||
|
startTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main component
|
||||||
|
*/
|
||||||
|
export default function FlowFundingV2() {
|
||||||
|
// Scenario selection
|
||||||
|
const [currentScenario, setCurrentScenario] = useState<ScenarioV2>(linearChainV2)
|
||||||
|
|
||||||
|
// Node state (with adjustable external inflows)
|
||||||
|
const [nodes, setNodes] = useState<FlowNode[]>(() =>
|
||||||
|
cloneNodes(currentScenario.nodes)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network state (calculated)
|
||||||
|
const [network, setNetwork] = useState<FlowNetwork | null>(null)
|
||||||
|
|
||||||
|
// Animation state
|
||||||
|
const [particles, setParticles] = useState<FlowParticle[]>([])
|
||||||
|
const [isPlaying, setIsPlaying] = useState(true)
|
||||||
|
const [simulationTime, setSimulationTime] = useState(0)
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const [showMetrics, setShowMetrics] = useState(true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate network whenever nodes change
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const result = calculateSteadyState(cloneNodes(nodes), {
|
||||||
|
verbose: false,
|
||||||
|
})
|
||||||
|
setNetwork(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to calculate steady state:', error)
|
||||||
|
}
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scenario change
|
||||||
|
*/
|
||||||
|
const handleScenarioChange = useCallback((scenario: ScenarioV2) => {
|
||||||
|
setCurrentScenario(scenario)
|
||||||
|
setNodes(cloneNodes(scenario.nodes))
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setSimulationTime(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle external inflow adjustment
|
||||||
|
*/
|
||||||
|
const handleInflowChange = useCallback(
|
||||||
|
(nodeId: string, newInflow: number) => {
|
||||||
|
setNodes(prev =>
|
||||||
|
prev.map(n =>
|
||||||
|
n.id === nodeId ? { ...n, externalInflow: newInflow } : n
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation loop - update balances and particles
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !network) return
|
||||||
|
|
||||||
|
let lastTime = performance.now()
|
||||||
|
let animationFrameId: number
|
||||||
|
|
||||||
|
const animate = (currentTime: number) => {
|
||||||
|
const deltaMs = currentTime - lastTime
|
||||||
|
lastTime = currentTime
|
||||||
|
|
||||||
|
const deltaSeconds = deltaMs / 1000
|
||||||
|
|
||||||
|
// Update simulation time
|
||||||
|
setSimulationTime(prev => prev + deltaSeconds)
|
||||||
|
|
||||||
|
// Update node balances (for visualization)
|
||||||
|
const updatedNodes = cloneNodes(nodes)
|
||||||
|
|
||||||
|
// Set total inflows/outflows from network calculation
|
||||||
|
if (network?.nodes) {
|
||||||
|
updatedNodes.forEach(node => {
|
||||||
|
const networkNode = network.nodes.get(node.id)
|
||||||
|
if (networkNode) {
|
||||||
|
node.totalInflow = networkNode.totalInflow
|
||||||
|
node.totalOutflow = networkNode.totalOutflow
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBalances(updatedNodes, deltaSeconds)
|
||||||
|
setNodes(updatedNodes)
|
||||||
|
|
||||||
|
// Update particles
|
||||||
|
setParticles(prev => {
|
||||||
|
const updated = prev
|
||||||
|
.map(p => ({
|
||||||
|
...p,
|
||||||
|
progress: p.progress + deltaSeconds / 2, // 2 second transit time
|
||||||
|
}))
|
||||||
|
.filter(p => p.progress < 1)
|
||||||
|
|
||||||
|
// Spawn new particles
|
||||||
|
const now = currentTime / 1000
|
||||||
|
if (network?.edges) {
|
||||||
|
network.edges.forEach(edge => {
|
||||||
|
// Spawn rate based on flow amount
|
||||||
|
const spawnRate = Math.min(2, Math.max(0.2, edge.flowRate / 500))
|
||||||
|
const shouldSpawn = Math.random() < spawnRate * deltaSeconds
|
||||||
|
|
||||||
|
if (shouldSpawn) {
|
||||||
|
updated.push({
|
||||||
|
id: `${edge.source}-${edge.target}-${now}-${Math.random()}`,
|
||||||
|
sourceId: edge.source,
|
||||||
|
targetId: edge.target,
|
||||||
|
progress: 0,
|
||||||
|
startTime: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationFrameId)
|
||||||
|
}
|
||||||
|
}, [isPlaying, network, nodes])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node position
|
||||||
|
*/
|
||||||
|
const getNodePos = useCallback(
|
||||||
|
(nodeId: string): { x: number; y: number } => {
|
||||||
|
return currentScenario.layout.get(nodeId) || { x: 0, y: 0 }
|
||||||
|
},
|
||||||
|
[currentScenario]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for flow zone
|
||||||
|
*/
|
||||||
|
const getZoneColor = useCallback((node: FlowNode): string => {
|
||||||
|
const zone = getFlowZone(node)
|
||||||
|
switch (zone) {
|
||||||
|
case 'deficit':
|
||||||
|
return '#ef4444' // red
|
||||||
|
case 'building':
|
||||||
|
return '#f59e0b' // amber
|
||||||
|
case 'capacity':
|
||||||
|
return '#10b981' // green
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render network SVG
|
||||||
|
*/
|
||||||
|
const renderNetwork = useMemo(() => {
|
||||||
|
if (!network) return null
|
||||||
|
|
||||||
|
const svgWidth = 800
|
||||||
|
const svgHeight = 650
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={svgWidth}
|
||||||
|
height={svgHeight}
|
||||||
|
className="border border-gray-700 rounded-lg bg-gray-900"
|
||||||
|
>
|
||||||
|
{/* Edges */}
|
||||||
|
{network.edges.map(edge => {
|
||||||
|
const source = getNodePos(edge.source)
|
||||||
|
const target = getNodePos(edge.target)
|
||||||
|
|
||||||
|
// Edge width based on flow rate (logarithmic scale)
|
||||||
|
const baseWidth = 2
|
||||||
|
const maxWidth = 12
|
||||||
|
const flowWidth =
|
||||||
|
baseWidth +
|
||||||
|
(maxWidth - baseWidth) *
|
||||||
|
Math.min(1, Math.log(edge.flowRate + 1) / Math.log(1000))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={`${edge.source}-${edge.target}`}>
|
||||||
|
{/* Edge line */}
|
||||||
|
<line
|
||||||
|
x1={source.x}
|
||||||
|
y1={source.y}
|
||||||
|
x2={target.x}
|
||||||
|
y2={target.y}
|
||||||
|
stroke="#4b5563"
|
||||||
|
strokeWidth={flowWidth}
|
||||||
|
strokeOpacity={0.6}
|
||||||
|
markerEnd="url(#arrowhead)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Flow label */}
|
||||||
|
<text
|
||||||
|
x={(source.x + target.x) / 2}
|
||||||
|
y={(source.y + target.y) / 2 - 5}
|
||||||
|
fill="#9ca3af"
|
||||||
|
fontSize="11"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="pointer-events-none"
|
||||||
|
>
|
||||||
|
${edge.flowRate.toFixed(0)}/mo
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Flow particles */}
|
||||||
|
{particles.map(particle => {
|
||||||
|
const source = getNodePos(particle.sourceId)
|
||||||
|
const target = getNodePos(particle.targetId)
|
||||||
|
|
||||||
|
const x = source.x + (target.x - source.x) * particle.progress
|
||||||
|
const y = source.y + (target.y - source.y) * particle.progress
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={particle.id}
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r={3}
|
||||||
|
fill="#3b82f6"
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{Array.from(network.nodes.values()).map(node => {
|
||||||
|
const pos = getNodePos(node.id)
|
||||||
|
const zone = getFlowZone(node)
|
||||||
|
const color = getZoneColor(node)
|
||||||
|
const isSelected = selectedNodeId === node.id
|
||||||
|
|
||||||
|
const totalInflow = node.totalInflow || 0
|
||||||
|
const totalOutflow = node.totalOutflow || 0
|
||||||
|
const retention = totalInflow - totalOutflow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={node.id}
|
||||||
|
onClick={() => setSelectedNodeId(node.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{/* Selection ring */}
|
||||||
|
{isSelected && (
|
||||||
|
<circle
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={38}
|
||||||
|
fill="none"
|
||||||
|
stroke="#a855f7"
|
||||||
|
strokeWidth={3}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Node circle */}
|
||||||
|
<circle
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={30}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={0.2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Node label */}
|
||||||
|
<text
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y - 5}
|
||||||
|
fill="white"
|
||||||
|
fontSize="13"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="pointer-events-none"
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Zone indicator */}
|
||||||
|
<text
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y + 8}
|
||||||
|
fill={color}
|
||||||
|
fontSize="10"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="pointer-events-none"
|
||||||
|
>
|
||||||
|
{zone}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Retention rate */}
|
||||||
|
<text
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y + 20}
|
||||||
|
fill="#9ca3af"
|
||||||
|
fontSize="9"
|
||||||
|
textAnchor="middle"
|
||||||
|
className="pointer-events-none"
|
||||||
|
>
|
||||||
|
+${retention.toFixed(0)}/mo
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Overflow node */}
|
||||||
|
{network.overflowNode && (
|
||||||
|
<g>
|
||||||
|
<circle
|
||||||
|
cx={svgWidth - 80}
|
||||||
|
cy={svgHeight - 80}
|
||||||
|
r={30}
|
||||||
|
fill="#6b7280"
|
||||||
|
fillOpacity={0.2}
|
||||||
|
stroke="#6b7280"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={svgWidth - 80}
|
||||||
|
y={svgHeight - 85}
|
||||||
|
fill="white"
|
||||||
|
fontSize="11"
|
||||||
|
fontWeight="bold"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
Overflow
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={svgWidth - 80}
|
||||||
|
y={svgHeight - 72}
|
||||||
|
fill="#9ca3af"
|
||||||
|
fontSize="9"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
${network.overflowNode.totalInflow.toFixed(0)}/mo
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arrow marker definition */}
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrowhead"
|
||||||
|
markerWidth="10"
|
||||||
|
markerHeight="10"
|
||||||
|
refX="9"
|
||||||
|
refY="3"
|
||||||
|
orient="auto"
|
||||||
|
>
|
||||||
|
<polygon points="0 0, 10 3, 0 6" fill="#4b5563" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}, [network, particles, selectedNodeId, getNodePos, getZoneColor, currentScenario])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 text-white p-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-2">
|
||||||
|
Flow Funding V2
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-lg">
|
||||||
|
Continuous flow dynamics with progressive outflow zones
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="mb-6 flex gap-4 items-center flex-wrap">
|
||||||
|
{/* Scenario selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Scenario
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={currentScenario.id}
|
||||||
|
onChange={e => {
|
||||||
|
const scenario = ALL_SCENARIOS_V2.find(
|
||||||
|
s => s.id === e.target.value
|
||||||
|
)
|
||||||
|
if (scenario) handleScenarioChange(scenario)
|
||||||
|
}}
|
||||||
|
className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
{ALL_SCENARIOS_V2.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Play/pause */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Animation
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPlaying(!isPlaying)}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded px-4 py-2"
|
||||||
|
>
|
||||||
|
{isPlaying ? '⏸ Pause' : '▶ Play'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Display
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMetrics(!showMetrics)}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded px-4 py-2"
|
||||||
|
>
|
||||||
|
{showMetrics ? '📊 Metrics On' : '📊 Metrics Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulation time */}
|
||||||
|
<div className="ml-auto">
|
||||||
|
<label className="block text-sm text-gray-400 mb-1">
|
||||||
|
Simulation Time
|
||||||
|
</label>
|
||||||
|
<div className="text-lg font-mono">
|
||||||
|
{simulationTime.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scenario description */}
|
||||||
|
<div className="mb-6 p-4 bg-gray-900 border border-gray-800 rounded-lg">
|
||||||
|
<p className="text-gray-300">{currentScenario.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Network visualization */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{renderNetwork}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control panel */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-bold mb-4">External Inflows</h3>
|
||||||
|
|
||||||
|
{/* Node inflow sliders */}
|
||||||
|
{nodes.map(node => {
|
||||||
|
const networkNode = network?.nodes.get(node.id)
|
||||||
|
const zone = networkNode ? getFlowZone(networkNode) : 'deficit'
|
||||||
|
const color = networkNode ? getZoneColor(networkNode) : '#ef4444'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={node.id}
|
||||||
|
className={`p-4 rounded-lg border-2 ${
|
||||||
|
selectedNodeId === node.id
|
||||||
|
? 'border-purple-500 bg-purple-950/20'
|
||||||
|
: 'border-gray-800 bg-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Node name and zone */}
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="font-semibold">{node.name}</span>
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-1 rounded"
|
||||||
|
style={{ backgroundColor: color + '40', color }}
|
||||||
|
>
|
||||||
|
{zone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* External inflow slider */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">
|
||||||
|
External Inflow: ${node.externalInflow.toFixed(0)}/mo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2000}
|
||||||
|
step={50}
|
||||||
|
value={node.externalInflow}
|
||||||
|
onChange={e =>
|
||||||
|
handleInflowChange(node.id, parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thresholds */}
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<div>Min: ${node.minThreshold}/mo</div>
|
||||||
|
<div>Max: ${node.maxThreshold}/mo</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow metrics */}
|
||||||
|
{showMetrics && networkNode && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-800 text-xs space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Total In:</span>
|
||||||
|
<span className="text-green-400">
|
||||||
|
${(networkNode.totalInflow || 0).toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Total Out:</span>
|
||||||
|
<span className="text-red-400">
|
||||||
|
${(networkNode.totalOutflow || 0).toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-semibold">
|
||||||
|
<span className="text-gray-400">Retained:</span>
|
||||||
|
<span className="text-blue-400">
|
||||||
|
$
|
||||||
|
{(
|
||||||
|
(networkNode.totalInflow || 0) -
|
||||||
|
(networkNode.totalOutflow || 0)
|
||||||
|
).toFixed(0)}
|
||||||
|
/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Balance:</span>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
${(networkNode.balance || 0).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Network totals */}
|
||||||
|
{showMetrics && network && (
|
||||||
|
<div className="p-4 bg-gray-900 border-2 border-gray-800 rounded-lg">
|
||||||
|
<h4 className="font-semibold mb-3">Network Totals</h4>
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">External Inflow:</span>
|
||||||
|
<span className="text-green-400">
|
||||||
|
${network.totalExternalInflow.toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Network Needs:</span>
|
||||||
|
<span className="text-amber-400">
|
||||||
|
${network.totalNetworkNeeds.toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-400">Network Capacity:</span>
|
||||||
|
<span className="text-blue-400">
|
||||||
|
${network.totalNetworkCapacity.toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{network.overflowNode && (
|
||||||
|
<div className="flex justify-between pt-2 border-t border-gray-800">
|
||||||
|
<span className="text-gray-400">Overflow:</span>
|
||||||
|
<span className="text-gray-400">
|
||||||
|
${network.overflowNode.totalInflow.toFixed(0)}/mo
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-2 border-t border-gray-800">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-400">Converged:</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
network.converged ? 'text-green-400' : 'text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{network.converged ? '✓ Yes' : '✗ No'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{network.iterations} iterations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-8 p-4 bg-gray-900 border border-gray-800 rounded-lg">
|
||||||
|
<h4 className="font-semibold mb-3">Flow Zones</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-4 h-4 mt-0.5 rounded-full bg-red-500"></div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-red-400">Deficit Zone</div>
|
||||||
|
<div className="text-gray-400 text-xs">
|
||||||
|
Inflow below min threshold. Keep everything (0% outflow).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-4 h-4 mt-0.5 rounded-full bg-amber-500"></div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-amber-400">Building Zone</div>
|
||||||
|
<div className="text-gray-400 text-xs">
|
||||||
|
Between min and max. Progressive sharing based on capacity.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-4 h-4 mt-0.5 rounded-full bg-green-500"></div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-green-400">Capacity Zone</div>
|
||||||
|
<div className="text-gray-400 text-xs">
|
||||||
|
Above max threshold. Redirect 100% of excess.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -5,11 +5,106 @@ import type React from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
|
// Inline Propagator class (simplified from @folkjs/propagators)
|
||||||
|
type PropagatorFunction = (source: EventTarget, target: EventTarget, event: Event) => any
|
||||||
|
|
||||||
|
interface PropagatorOptions {
|
||||||
|
source?: EventTarget | null
|
||||||
|
target?: EventTarget | null
|
||||||
|
event?: string | null
|
||||||
|
handler?: PropagatorFunction | null
|
||||||
|
}
|
||||||
|
|
||||||
|
class Propagator {
|
||||||
|
private source: EventTarget | null = null
|
||||||
|
private target: EventTarget | null = null
|
||||||
|
private eventName: string | null = null
|
||||||
|
private handler: PropagatorFunction | null = null
|
||||||
|
|
||||||
|
constructor(options: PropagatorOptions = {}) {
|
||||||
|
const { source = null, target = null, event = null, handler = null } = options
|
||||||
|
|
||||||
|
this.source = source
|
||||||
|
this.target = target
|
||||||
|
this.eventName = event
|
||||||
|
this.handler = handler
|
||||||
|
|
||||||
|
// Add listener if we have all necessary parts
|
||||||
|
if (this.source && this.eventName) {
|
||||||
|
this.source.addEventListener(this.eventName, this.handleEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEvent = (event: Event) => {
|
||||||
|
if (!this.source || !this.target || !this.handler) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.handler(this.source, this.target, event)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in propagator handler:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
propagate(event?: Event): void {
|
||||||
|
if (!event && this.eventName) {
|
||||||
|
event = new Event(this.eventName)
|
||||||
|
}
|
||||||
|
if (!event) return
|
||||||
|
this.handleEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this.source && this.eventName) {
|
||||||
|
this.source.removeEventListener(this.eventName, this.handleEvent)
|
||||||
|
}
|
||||||
|
this.source = null
|
||||||
|
this.target = null
|
||||||
|
this.handler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Tool = "select" | "draw" | "erase" | "rectangle" | "text" | "arrow"
|
type Tool = "select" | "draw" | "erase" | "rectangle" | "text" | "arrow"
|
||||||
|
|
||||||
|
// Helper function to calculate distance from point to line segment
|
||||||
|
function pointToLineDistance(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
||||||
|
const A = px - x1
|
||||||
|
const B = py - y1
|
||||||
|
const C = x2 - x1
|
||||||
|
const D = y2 - y1
|
||||||
|
|
||||||
|
const dot = A * C + B * D
|
||||||
|
const lenSq = C * C + D * D
|
||||||
|
let param = -1
|
||||||
|
|
||||||
|
if (lenSq !== 0) {
|
||||||
|
param = dot / lenSq
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx, yy
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1
|
||||||
|
yy = y1
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2
|
||||||
|
yy = y2
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C
|
||||||
|
yy = y1 + param * D
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = px - xx
|
||||||
|
const dy = py - yy
|
||||||
|
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const HIT_TOLERANCE = 10 // pixels - for line/arrow hit detection
|
||||||
|
|
||||||
interface Shape {
|
interface Shape {
|
||||||
id: string
|
id: string
|
||||||
type: "rectangle" | "ellipse" | "line" | "text"
|
type: "rectangle" | "ellipse" | "line" | "text" | "arrow"
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
width?: number
|
width?: number
|
||||||
|
|
@ -18,6 +113,13 @@ interface Shape {
|
||||||
y2?: number
|
y2?: number
|
||||||
text?: string
|
text?: string
|
||||||
color: string
|
color: string
|
||||||
|
// For arrows that connect shapes
|
||||||
|
sourceShapeId?: string
|
||||||
|
targetShapeId?: string
|
||||||
|
// Propagator expression for live arrows
|
||||||
|
expression?: string
|
||||||
|
// Data value for shapes (used in propagation)
|
||||||
|
value?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ItalismPage() {
|
export default function ItalismPage() {
|
||||||
|
|
@ -54,6 +156,224 @@ export default function ItalismPage() {
|
||||||
const [currentShape, setCurrentShape] = useState<Partial<Shape> | null>(null)
|
const [currentShape, setCurrentShape] = useState<Partial<Shape> | null>(null)
|
||||||
const [selectedShape, setSelectedShape] = useState<string | null>(null)
|
const [selectedShape, setSelectedShape] = useState<string | null>(null)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const [arrowStartShape, setArrowStartShape] = useState<string | null>(null)
|
||||||
|
const [propagators, setPropagators] = useState<Map<string, Propagator>>(new Map())
|
||||||
|
const [editingArrow, setEditingArrow] = useState<string | null>(null)
|
||||||
|
const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map())
|
||||||
|
|
||||||
|
// Undo/Redo state - using useRef to avoid stale closure issues
|
||||||
|
const historyRef = useRef<Shape[][]>([])
|
||||||
|
const historyIndexRef = useRef(-1)
|
||||||
|
const [, forceUpdate] = useState({})
|
||||||
|
const isInitialized = useRef(false)
|
||||||
|
|
||||||
|
// Initialize history with current shapes on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized.current) {
|
||||||
|
historyRef.current = [JSON.parse(JSON.stringify(shapes))]
|
||||||
|
historyIndexRef.current = 0
|
||||||
|
isInitialized.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save state to history (called after any shape modification)
|
||||||
|
const saveToHistory = (newShapes: Shape[]) => {
|
||||||
|
if (!isInitialized.current) return
|
||||||
|
|
||||||
|
// Truncate history after current index (discard redo states)
|
||||||
|
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1)
|
||||||
|
|
||||||
|
// Add new state (deep clone to prevent reference issues)
|
||||||
|
historyRef.current.push(JSON.parse(JSON.stringify(newShapes)))
|
||||||
|
|
||||||
|
// Limit to 50 states to prevent memory issues
|
||||||
|
if (historyRef.current.length > 50) {
|
||||||
|
historyRef.current.shift()
|
||||||
|
} else {
|
||||||
|
historyIndexRef.current++
|
||||||
|
}
|
||||||
|
|
||||||
|
setShapes(newShapes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undo function - go back one state
|
||||||
|
const undo = () => {
|
||||||
|
if (historyIndexRef.current > 0) {
|
||||||
|
historyIndexRef.current--
|
||||||
|
const previousState = historyRef.current[historyIndexRef.current]
|
||||||
|
setShapes(previousState)
|
||||||
|
forceUpdate({}) // Force re-render to update button states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo function - go forward one state
|
||||||
|
const redo = () => {
|
||||||
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||||
|
historyIndexRef.current++
|
||||||
|
const nextState = historyRef.current[historyIndexRef.current]
|
||||||
|
setShapes(nextState)
|
||||||
|
forceUpdate({}) // Force re-render to update button states
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+Z or Cmd+Z for undo
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
undo()
|
||||||
|
}
|
||||||
|
// Ctrl+Shift+Z or Cmd+Shift+Z for redo
|
||||||
|
else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
redo()
|
||||||
|
}
|
||||||
|
// Delete key to delete selected shape
|
||||||
|
else if (e.key === 'Delete' && selectedShape) {
|
||||||
|
e.preventDefault()
|
||||||
|
const clicked = shapes.find(s => s.id === selectedShape)
|
||||||
|
if (clicked) {
|
||||||
|
// Cleanup propagator if deleting an arrow
|
||||||
|
if (clicked.type === "arrow") {
|
||||||
|
const propagator = propagators.get(clicked.id)
|
||||||
|
if (propagator) {
|
||||||
|
propagator.dispose()
|
||||||
|
setPropagators((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(clicked.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setEventTargets((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(clicked.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const newShapes = shapes.filter((shape) => shape.id !== clicked.id)
|
||||||
|
saveToHistory(newShapes)
|
||||||
|
setSelectedShape(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Escape to deselect
|
||||||
|
else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedShape(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [selectedShape, shapes, propagators])
|
||||||
|
|
||||||
|
// Helper function to get the center of a shape
|
||||||
|
const getShapeCenter = (shape: Shape): { x: number; y: number } => {
|
||||||
|
if (shape.width && shape.height) {
|
||||||
|
return {
|
||||||
|
x: shape.x + shape.width / 2,
|
||||||
|
y: shape.y + shape.height / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: shape.x, y: shape.y }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to find shape at coordinates (excludes arrows/lines - used for arrow tool)
|
||||||
|
const findShapeAt = (x: number, y: number): Shape | null => {
|
||||||
|
return (
|
||||||
|
shapes.find((shape) => {
|
||||||
|
if (shape.type === "arrow" || shape.type === "line") return false
|
||||||
|
if (shape.width && shape.height) {
|
||||||
|
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
||||||
|
} else if (shape.type === "text" && shape.text) {
|
||||||
|
const textWidth = shape.text.length * 12
|
||||||
|
const textHeight = 20
|
||||||
|
return x >= shape.x && x <= shape.x + textWidth && y >= shape.y - textHeight && y <= shape.y
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}) || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to check if a point is inside/near a shape (includes all shape types)
|
||||||
|
const isPointInShape = (x: number, y: number, shape: Shape): boolean => {
|
||||||
|
if (shape.width && shape.height) {
|
||||||
|
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
||||||
|
} else if (shape.type === "text" && shape.text) {
|
||||||
|
const textWidth = shape.text.length * 12
|
||||||
|
const textHeight = 20
|
||||||
|
return x >= shape.x && x <= shape.x + textWidth && y >= shape.y - textHeight && y <= shape.y
|
||||||
|
} else if ((shape.type === "line" || shape.type === "arrow") && shape.x2 && shape.y2) {
|
||||||
|
const distance = pointToLineDistance(x, y, shape.x, shape.y, shape.x2, shape.y2)
|
||||||
|
return distance < HIT_TOLERANCE
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a simple propagator for an arrow
|
||||||
|
const createPropagatorForArrow = (arrow: Shape) => {
|
||||||
|
if (!arrow.sourceShapeId || !arrow.targetShapeId) return
|
||||||
|
|
||||||
|
const sourceShape = shapes.find((s) => s.id === arrow.sourceShapeId)
|
||||||
|
const targetShape = shapes.find((s) => s.id === arrow.targetShapeId)
|
||||||
|
|
||||||
|
if (!sourceShape || !targetShape) return
|
||||||
|
|
||||||
|
// Create EventTargets for the connection
|
||||||
|
const mockSource = new EventTarget()
|
||||||
|
const mockTarget = new EventTarget()
|
||||||
|
|
||||||
|
// Store shape IDs on EventTargets so handler can reference them
|
||||||
|
;(mockSource as any)._shapeId = arrow.sourceShapeId
|
||||||
|
;(mockTarget as any)._shapeId = arrow.targetShapeId
|
||||||
|
|
||||||
|
const expression = arrow.expression || "value: from.value"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const propagator = new Propagator({
|
||||||
|
source: mockSource,
|
||||||
|
target: mockTarget,
|
||||||
|
event: "update",
|
||||||
|
handler: (from: any, to: any) => {
|
||||||
|
// Use setShapes with function to get CURRENT state (avoid stale closure)
|
||||||
|
setShapes((currentShapes) => {
|
||||||
|
const currentSourceShape = currentShapes.find((s) => s.id === (from as any)._shapeId)
|
||||||
|
if (!currentSourceShape || currentSourceShape.value === undefined) {
|
||||||
|
console.log("⚠️ No source value to propagate")
|
||||||
|
return currentShapes // return unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceValue = currentSourceShape.value
|
||||||
|
const targetId = (to as any)._shapeId
|
||||||
|
|
||||||
|
console.log(`✅ Propagating from ${(from as any)._shapeId} to ${targetId}: ${sourceValue}`)
|
||||||
|
|
||||||
|
// Update target shape with value
|
||||||
|
return currentShapes.map((s) =>
|
||||||
|
s.id === targetId ? { ...s, value: sourceValue } : s
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store both propagator and EventTargets in their respective Maps
|
||||||
|
setPropagators((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(arrow.id, propagator)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setEventTargets((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(arrow.id, { source: mockSource, target: mockTarget })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create propagator:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
|
|
@ -70,6 +390,26 @@ export default function ItalismPage() {
|
||||||
ctx.fillStyle = "#0f172a"
|
ctx.fillStyle = "#0f172a"
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Helper function to draw arrowhead
|
||||||
|
const drawArrowhead = (x1: number, y1: number, x2: number, y2: number, color: string) => {
|
||||||
|
const headLength = 15
|
||||||
|
const angle = Math.atan2(y2 - y1, x2 - x1)
|
||||||
|
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x2, y2)
|
||||||
|
ctx.lineTo(
|
||||||
|
x2 - headLength * Math.cos(angle - Math.PI / 6),
|
||||||
|
y2 - headLength * Math.sin(angle - Math.PI / 6)
|
||||||
|
)
|
||||||
|
ctx.lineTo(
|
||||||
|
x2 - headLength * Math.cos(angle + Math.PI / 6),
|
||||||
|
y2 - headLength * Math.sin(angle + Math.PI / 6)
|
||||||
|
)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
// Draw shapes
|
// Draw shapes
|
||||||
shapes.forEach((shape) => {
|
shapes.forEach((shape) => {
|
||||||
ctx.strokeStyle = shape.color
|
ctx.strokeStyle = shape.color
|
||||||
|
|
@ -102,6 +442,23 @@ export default function ItalismPage() {
|
||||||
ctx.moveTo(shape.x, shape.y)
|
ctx.moveTo(shape.x, shape.y)
|
||||||
ctx.lineTo(shape.x2, shape.y2)
|
ctx.lineTo(shape.x2, shape.y2)
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
} else if (shape.type === "arrow" && shape.x2 && shape.y2) {
|
||||||
|
// Draw arrow line with highlight if selected
|
||||||
|
const isSelected = selectedShape === shape.id
|
||||||
|
ctx.strokeStyle = isSelected ? "#22d3ee" : shape.color
|
||||||
|
ctx.lineWidth = isSelected ? 4 : 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(shape.x, shape.y)
|
||||||
|
ctx.lineTo(shape.x2, shape.y2)
|
||||||
|
ctx.stroke()
|
||||||
|
// Draw arrowhead
|
||||||
|
drawArrowhead(shape.x, shape.y, shape.x2, shape.y2, isSelected ? "#22d3ee" : shape.color)
|
||||||
|
// Reset line width
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
} else if (shape.type === "text" && shape.text) {
|
||||||
|
ctx.font = "20px sans-serif"
|
||||||
|
ctx.fillStyle = shape.color
|
||||||
|
ctx.fillText(shape.text, shape.x, shape.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Highlight selected shape
|
// Highlight selected shape
|
||||||
|
|
@ -110,8 +467,49 @@ export default function ItalismPage() {
|
||||||
ctx.lineWidth = 3
|
ctx.lineWidth = 3
|
||||||
ctx.strokeRect(shape.x - 5, shape.y - 5, shape.width + 10, shape.height + 10)
|
ctx.strokeRect(shape.x - 5, shape.y - 5, shape.width + 10, shape.height + 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show connection point (center dot) for shapes that can be connected
|
||||||
|
if (shape.type !== "arrow" && shape.type !== "line" && (shape.width || shape.height)) {
|
||||||
|
const center = getShapeCenter(shape)
|
||||||
|
ctx.fillStyle = "#22d3ee"
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(center.x, center.y, 3, 0, 2 * Math.PI)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show value label if shape has a value
|
||||||
|
if (shape.value !== undefined && shape.type !== "arrow") {
|
||||||
|
ctx.fillStyle = "#fbbf24"
|
||||||
|
ctx.font = "bold 12px sans-serif"
|
||||||
|
ctx.fillText(`${shape.value}`, shape.x + 5, shape.y - 5)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}, [shapes, selectedShape])
|
|
||||||
|
// Draw current shape being drawn
|
||||||
|
if (currentShape && isDrawing) {
|
||||||
|
ctx.strokeStyle = currentShape.color || "#6366f1"
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.fillStyle = currentShape.color || "#6366f1"
|
||||||
|
ctx.setLineDash([5, 5]) // Dashed line for preview
|
||||||
|
|
||||||
|
if (currentShape.type === "rectangle" && currentShape.width && currentShape.height) {
|
||||||
|
ctx.strokeRect(currentShape.x || 0, currentShape.y || 0, currentShape.width, currentShape.height)
|
||||||
|
} else if (currentShape.type === "line" && currentShape.x2 && currentShape.y2) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(currentShape.x || 0, currentShape.y || 0)
|
||||||
|
ctx.lineTo(currentShape.x2, currentShape.y2)
|
||||||
|
ctx.stroke()
|
||||||
|
} else if (currentShape.type === "arrow" && currentShape.x2 && currentShape.y2) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(currentShape.x || 0, currentShape.y || 0)
|
||||||
|
ctx.lineTo(currentShape.x2, currentShape.y2)
|
||||||
|
ctx.stroke()
|
||||||
|
drawArrowhead(currentShape.x || 0, currentShape.y || 0, currentShape.x2, currentShape.y2, currentShape.color || "#6366f1")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.setLineDash([]) // Reset to solid line
|
||||||
|
}
|
||||||
|
}, [shapes, selectedShape, currentShape, isDrawing])
|
||||||
|
|
||||||
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
|
|
@ -122,14 +520,76 @@ export default function ItalismPage() {
|
||||||
const y = e.clientY - rect.top
|
const y = e.clientY - rect.top
|
||||||
|
|
||||||
if (tool === "select") {
|
if (tool === "select") {
|
||||||
// Find clicked shape
|
// Find clicked shape (including arrows)
|
||||||
const clicked = shapes.find((shape) => {
|
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
|
||||||
if (shape.width && shape.height) {
|
|
||||||
return x >= shape.x && x <= shape.x + shape.width && y >= shape.y && y <= shape.y + shape.height
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
setSelectedShape(clicked?.id || null)
|
setSelectedShape(clicked?.id || null)
|
||||||
|
|
||||||
|
// If a shape was clicked, prepare for dragging (but not for arrows - they're connections)
|
||||||
|
if (clicked && clicked.type !== "arrow" && clicked.type !== "line") {
|
||||||
|
setIsDragging(true)
|
||||||
|
setDragOffset({
|
||||||
|
x: x - clicked.x,
|
||||||
|
y: y - clicked.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (tool === "erase") {
|
||||||
|
// Find and delete clicked shape
|
||||||
|
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
// Cleanup propagator if deleting an arrow
|
||||||
|
if (clicked.type === "arrow") {
|
||||||
|
const propagator = propagators.get(clicked.id)
|
||||||
|
if (propagator) {
|
||||||
|
propagator.dispose()
|
||||||
|
setPropagators((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(clicked.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setEventTargets((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.delete(clicked.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const newShapes = shapes.filter((shape) => shape.id !== clicked.id)
|
||||||
|
saveToHistory(newShapes)
|
||||||
|
setSelectedShape(null)
|
||||||
|
}
|
||||||
|
} else if (tool === "text") {
|
||||||
|
// Prompt for text input
|
||||||
|
const text = prompt("Enter text:")
|
||||||
|
if (text) {
|
||||||
|
const newShape: Shape = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "text",
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
text,
|
||||||
|
color: "#6366f1",
|
||||||
|
}
|
||||||
|
saveToHistory([...shapes, newShape])
|
||||||
|
}
|
||||||
|
} else if (tool === "arrow") {
|
||||||
|
// Special handling for arrow tool - snap to shapes
|
||||||
|
const shapeAtClick = findShapeAt(x, y)
|
||||||
|
|
||||||
|
if (shapeAtClick) {
|
||||||
|
// Clicked on a shape - start arrow from its center
|
||||||
|
setArrowStartShape(shapeAtClick.id)
|
||||||
|
const center = getShapeCenter(shapeAtClick)
|
||||||
|
setIsDrawing(true)
|
||||||
|
setCurrentShape({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: "arrow",
|
||||||
|
x: center.x,
|
||||||
|
y: center.y,
|
||||||
|
color: "#6366f1",
|
||||||
|
sourceShapeId: shapeAtClick.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
} else if (tool === "draw" || tool === "rectangle") {
|
} else if (tool === "draw" || tool === "rectangle") {
|
||||||
setIsDrawing(true)
|
setIsDrawing(true)
|
||||||
setCurrentShape({
|
setCurrentShape({
|
||||||
|
|
@ -143,8 +603,6 @@ export default function ItalismPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!isDrawing || !currentShape) return
|
|
||||||
|
|
||||||
const canvas = canvasRef.current
|
const canvas = canvasRef.current
|
||||||
if (!canvas) return
|
if (!canvas) return
|
||||||
|
|
||||||
|
|
@ -152,27 +610,111 @@ export default function ItalismPage() {
|
||||||
const x = e.clientX - rect.left
|
const x = e.clientX - rect.left
|
||||||
const y = e.clientY - rect.top
|
const y = e.clientY - rect.top
|
||||||
|
|
||||||
if (currentShape.type === "rectangle") {
|
// Handle dragging selected shape
|
||||||
setCurrentShape({
|
if (isDragging && selectedShape && tool === "select") {
|
||||||
...currentShape,
|
setShapes(
|
||||||
width: x - (currentShape.x || 0),
|
shapes.map((shape) => {
|
||||||
height: y - (currentShape.y || 0),
|
if (shape.id === selectedShape) {
|
||||||
})
|
const newX = x - dragOffset.x
|
||||||
} else if (currentShape.type === "line") {
|
const newY = y - dragOffset.y
|
||||||
setCurrentShape({
|
|
||||||
...currentShape,
|
// For lines and arrows, also update the end points
|
||||||
x2: x,
|
if ((shape.type === "line" || shape.type === "arrow") && shape.x2 !== undefined && shape.y2 !== undefined) {
|
||||||
y2: y,
|
const dx = newX - shape.x
|
||||||
})
|
const dy = newY - shape.y
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
x2: shape.x2 + dx,
|
||||||
|
y2: shape.y2 + dy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shape,
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shape
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drawing new shapes
|
||||||
|
if (isDrawing && currentShape) {
|
||||||
|
if (currentShape.type === "rectangle") {
|
||||||
|
setCurrentShape({
|
||||||
|
...currentShape,
|
||||||
|
width: x - (currentShape.x || 0),
|
||||||
|
height: y - (currentShape.y || 0),
|
||||||
|
})
|
||||||
|
} else if (currentShape.type === "arrow") {
|
||||||
|
// For arrows, snap to target shape center if hovering over one
|
||||||
|
const shapeAtMouse = findShapeAt(x, y)
|
||||||
|
if (shapeAtMouse && shapeAtMouse.id !== arrowStartShape) {
|
||||||
|
const center = getShapeCenter(shapeAtMouse)
|
||||||
|
setCurrentShape({
|
||||||
|
...currentShape,
|
||||||
|
x2: center.x,
|
||||||
|
y2: center.y,
|
||||||
|
targetShapeId: shapeAtMouse.id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setCurrentShape({
|
||||||
|
...currentShape,
|
||||||
|
x2: x,
|
||||||
|
y2: y,
|
||||||
|
targetShapeId: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (currentShape.type === "line") {
|
||||||
|
setCurrentShape({
|
||||||
|
...currentShape,
|
||||||
|
x2: x,
|
||||||
|
y2: y,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (isDrawing && currentShape) {
|
if (isDrawing && currentShape) {
|
||||||
setShapes([...shapes, currentShape as Shape])
|
let newShape = currentShape as Shape
|
||||||
|
|
||||||
|
// Normalize rectangles with negative dimensions (drawn upward/leftward)
|
||||||
|
if (newShape.type === "rectangle" && newShape.width !== undefined && newShape.height !== undefined) {
|
||||||
|
if (newShape.width < 0) {
|
||||||
|
newShape.x = newShape.x + newShape.width
|
||||||
|
newShape.width = Math.abs(newShape.width)
|
||||||
|
}
|
||||||
|
if (newShape.height < 0) {
|
||||||
|
newShape.y = newShape.y + newShape.height
|
||||||
|
newShape.height = Math.abs(newShape.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an arrow with both source and target, create a propagator
|
||||||
|
const newShapesArray = [...shapes, newShape]
|
||||||
|
if (newShape.type === "arrow" && newShape.sourceShapeId && newShape.targetShapeId) {
|
||||||
|
newShape.expression = "value: from.value" // Default expression
|
||||||
|
saveToHistory(newShapesArray)
|
||||||
|
// Create propagator for this arrow
|
||||||
|
setTimeout(() => createPropagatorForArrow(newShape), 0)
|
||||||
|
} else {
|
||||||
|
saveToHistory(newShapesArray)
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentShape(null)
|
setCurrentShape(null)
|
||||||
|
} else if (isDragging) {
|
||||||
|
// Save to history when dragging stops
|
||||||
|
saveToHistory(shapes)
|
||||||
}
|
}
|
||||||
setIsDrawing(false)
|
setIsDrawing(false)
|
||||||
|
setIsDragging(false)
|
||||||
|
setArrowStartShape(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
|
|
@ -263,8 +805,144 @@ export default function ItalismPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Expression Editor */}
|
||||||
|
{selectedShape && shapes.find((s) => s.id === selectedShape)?.type === "arrow" && (
|
||||||
|
<div className="space-y-2 p-4 bg-slate-700 rounded">
|
||||||
|
<h3 className="text-sm font-semibold text-cyan-400">Live Arrow Properties</h3>
|
||||||
|
{(() => {
|
||||||
|
const arrow = shapes.find((s) => s.id === selectedShape)
|
||||||
|
if (!arrow) return null
|
||||||
|
|
||||||
|
const sourceShape = arrow.sourceShapeId
|
||||||
|
? shapes.find((s) => s.id === arrow.sourceShapeId)
|
||||||
|
: null
|
||||||
|
const targetShape = arrow.targetShapeId
|
||||||
|
? shapes.find((s) => s.id === arrow.targetShapeId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="text-slate-300">
|
||||||
|
<span className="text-white font-medium">From:</span>{" "}
|
||||||
|
{sourceShape?.text || sourceShape?.id || "None"}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-300">
|
||||||
|
<span className="text-white font-medium">To:</span>{" "}
|
||||||
|
{targetShape?.text || targetShape?.id || "None"}
|
||||||
|
</div>
|
||||||
|
{arrow.sourceShapeId && arrow.targetShapeId && (
|
||||||
|
<>
|
||||||
|
<div className="text-slate-300">
|
||||||
|
<label className="text-white font-medium block mb-1">Expression:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={arrow.expression || "value: from.value"}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShapes(
|
||||||
|
shapes.map((s) =>
|
||||||
|
s.id === arrow.id ? { ...s, expression: e.target.value } : s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Save to history when user finishes editing
|
||||||
|
saveToHistory(shapes)
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
|
||||||
|
placeholder="value: from.value * 2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Get EventTargets from the Map
|
||||||
|
const targets = eventTargets.get(arrow.id)
|
||||||
|
const sourceShape = shapes.find((s) => s.id === arrow.sourceShapeId)
|
||||||
|
|
||||||
|
if (!targets) {
|
||||||
|
console.warn("⚠️ No EventTarget found. Arrow may need to be re-created.")
|
||||||
|
alert("This arrow needs to be re-drawn. Please delete and create it again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceShape || sourceShape.value === undefined) {
|
||||||
|
console.warn("⚠️ Source shape has no value set")
|
||||||
|
alert(`Please set a value on "${sourceShape?.text || "the source shape"}" first.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger the propagation
|
||||||
|
targets.source.dispatchEvent(new Event("update"))
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 bg-cyan-600 hover:bg-cyan-700 rounded text-xs transition-colors"
|
||||||
|
>
|
||||||
|
Test Propagation
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shape Value Editor */}
|
||||||
|
{selectedShape && shapes.find((s) => s.id === selectedShape)?.type !== "arrow" && (
|
||||||
|
<div className="space-y-2 p-4 bg-slate-700 rounded">
|
||||||
|
<h3 className="text-sm font-semibold text-cyan-400">Shape Properties</h3>
|
||||||
|
{(() => {
|
||||||
|
const shape = shapes.find((s) => s.id === selectedShape)
|
||||||
|
if (!shape) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="text-slate-300">
|
||||||
|
<label className="text-white font-medium block mb-1">Value:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={shape.value || 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShapes(
|
||||||
|
shapes.map((s) =>
|
||||||
|
s.id === shape.id ? { ...s, value: parseFloat(e.target.value) || 0 } : s,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Save to history when user finishes editing
|
||||||
|
saveToHistory(shapes)
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-300 text-xs">
|
||||||
|
Arrows connected from this shape will propagate this value.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={undo}
|
||||||
|
disabled={historyIndexRef.current <= 0}
|
||||||
|
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-600 disabled:cursor-not-allowed rounded text-sm transition-colors"
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
↶ Undo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={redo}
|
||||||
|
disabled={historyIndexRef.current >= historyRef.current.length - 1}
|
||||||
|
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-600 disabled:cursor-not-allowed rounded text-sm transition-colors"
|
||||||
|
title="Redo (Ctrl+Shift+Z)"
|
||||||
|
>
|
||||||
|
↷ Redo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
className="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm transition-colors"
|
className="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm transition-colors"
|
||||||
|
|
@ -272,7 +950,7 @@ export default function ItalismPage() {
|
||||||
{isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
{isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShapes([])}
|
onClick={() => saveToHistory([])}
|
||||||
className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
|
className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Clear Canvas
|
Clear Canvas
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,778 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import type { FlowFundingNetwork, Allocation } from "@/lib/tbff/types"
|
||||||
|
import { renderNetwork } from "@/lib/tbff/rendering"
|
||||||
|
import { sampleNetworks, networkOptions, getSampleNetwork } from "@/lib/tbff/sample-networks"
|
||||||
|
import { formatCurrency, getStatusColorClass, normalizeAllocations, calculateNetworkTotals, updateAccountComputedProperties } from "@/lib/tbff/utils"
|
||||||
|
import { initialDistribution, getDistributionSummary } from "@/lib/tbff/algorithms"
|
||||||
|
|
||||||
|
type Tool = 'select' | 'create-allocation'
|
||||||
|
|
||||||
|
export default function TBFFPage() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const [network, setNetwork] = useState<FlowFundingNetwork>(sampleNetworks.statesDemo)
|
||||||
|
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null)
|
||||||
|
const [selectedAllocationId, setSelectedAllocationId] = useState<string | null>(null)
|
||||||
|
const [selectedNetworkKey, setSelectedNetworkKey] = useState<string>('statesDemo')
|
||||||
|
const [tool, setTool] = useState<Tool>('select')
|
||||||
|
const [allocationSourceId, setAllocationSourceId] = useState<string | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [draggedAccountId, setDraggedAccountId] = useState<string | null>(null)
|
||||||
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||||
|
const [mouseDownPos, setMouseDownPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const [fundingAmount, setFundingAmount] = useState(1000)
|
||||||
|
const [lastDistribution, setLastDistribution] = useState<{
|
||||||
|
totalDistributed: number
|
||||||
|
accountsChanged: number
|
||||||
|
changes: Array<{ accountId: string; name: string; before: number; after: number; delta: number }>
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// Render canvas whenever network changes
|
||||||
|
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
|
||||||
|
renderNetwork(ctx, network, canvas.width, canvas.height, selectedAccountId, selectedAllocationId)
|
||||||
|
}, [network, selectedAccountId, selectedAllocationId])
|
||||||
|
|
||||||
|
// Handle mouse down - record position for all interactions
|
||||||
|
const handleMouseDown = (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
|
||||||
|
|
||||||
|
// Always record mouse down position
|
||||||
|
setMouseDownPos({ x, y })
|
||||||
|
|
||||||
|
// Find clicked account
|
||||||
|
const clickedAccount = network.accounts.find(
|
||||||
|
(acc) =>
|
||||||
|
x >= acc.x &&
|
||||||
|
x <= acc.x + acc.width &&
|
||||||
|
y >= acc.y &&
|
||||||
|
y <= acc.y + acc.height
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prepare for potential drag (only in select mode)
|
||||||
|
if (tool === 'select' && clickedAccount) {
|
||||||
|
setDraggedAccountId(clickedAccount.id)
|
||||||
|
setDragOffset({
|
||||||
|
x: x - clickedAccount.x,
|
||||||
|
y: y - clickedAccount.y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse move - start drag if threshold exceeded (select mode only)
|
||||||
|
const handleMouseMove = (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
|
||||||
|
|
||||||
|
// Only handle dragging in select mode
|
||||||
|
if (tool === 'select' && mouseDownPos && draggedAccountId && !isDragging) {
|
||||||
|
const dx = x - mouseDownPos.x
|
||||||
|
const dy = y - mouseDownPos.y
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
// Start drag if moved more than 5 pixels
|
||||||
|
if (distance > 5) {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dragging, update position
|
||||||
|
if (isDragging && draggedAccountId) {
|
||||||
|
const updatedNetwork = calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
accounts: network.accounts.map((acc) =>
|
||||||
|
acc.id === draggedAccountId
|
||||||
|
? updateAccountComputedProperties({
|
||||||
|
...acc,
|
||||||
|
x: x - dragOffset.x,
|
||||||
|
y: y - dragOffset.y,
|
||||||
|
})
|
||||||
|
: acc
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
setNetwork(updatedNetwork)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse up - end dragging or handle click
|
||||||
|
const handleMouseUp = (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
|
||||||
|
|
||||||
|
// If was dragging, just end drag
|
||||||
|
if (isDragging) {
|
||||||
|
setIsDragging(false)
|
||||||
|
setDraggedAccountId(null)
|
||||||
|
setMouseDownPos(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear drag-related state
|
||||||
|
setDraggedAccountId(null)
|
||||||
|
setMouseDownPos(null)
|
||||||
|
|
||||||
|
// Find what was clicked
|
||||||
|
const clickedAccount = network.accounts.find(
|
||||||
|
(acc) =>
|
||||||
|
x >= acc.x &&
|
||||||
|
x <= acc.x + acc.width &&
|
||||||
|
y >= acc.y &&
|
||||||
|
y <= acc.y + acc.height
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle based on current tool
|
||||||
|
if (tool === 'select') {
|
||||||
|
if (clickedAccount) {
|
||||||
|
setSelectedAccountId(clickedAccount.id)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
} else {
|
||||||
|
// Check if clicked on an allocation arrow
|
||||||
|
const clickedAllocation = findAllocationAtPoint(x, y)
|
||||||
|
if (clickedAllocation) {
|
||||||
|
setSelectedAllocationId(clickedAllocation.id)
|
||||||
|
setSelectedAccountId(null)
|
||||||
|
} else {
|
||||||
|
setSelectedAccountId(null)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (tool === 'create-allocation') {
|
||||||
|
if (clickedAccount) {
|
||||||
|
if (!allocationSourceId) {
|
||||||
|
// First click - set source
|
||||||
|
setAllocationSourceId(clickedAccount.id)
|
||||||
|
} else {
|
||||||
|
// Second click - create allocation
|
||||||
|
if (clickedAccount.id !== allocationSourceId) {
|
||||||
|
createAllocation(allocationSourceId, clickedAccount.id)
|
||||||
|
}
|
||||||
|
setAllocationSourceId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle mouse leave - only cancel drag, don't deselect
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (isDragging) {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
setDraggedAccountId(null)
|
||||||
|
setMouseDownPos(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find allocation at point (simple distance check)
|
||||||
|
const findAllocationAtPoint = (x: number, y: number): Allocation | null => {
|
||||||
|
const tolerance = 15
|
||||||
|
|
||||||
|
for (const allocation of network.allocations) {
|
||||||
|
const source = network.accounts.find(a => a.id === allocation.sourceAccountId)
|
||||||
|
const target = network.accounts.find(a => a.id === allocation.targetAccountId)
|
||||||
|
|
||||||
|
if (!source || !target) continue
|
||||||
|
|
||||||
|
const sourceCenter = { x: source.x + source.width / 2, y: source.y + source.height / 2 }
|
||||||
|
const targetCenter = { x: target.x + target.width / 2, y: target.y + target.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 calculation
|
||||||
|
const pointToLineDistance = (
|
||||||
|
px: number,
|
||||||
|
py: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number
|
||||||
|
): number => {
|
||||||
|
const A = px - x1
|
||||||
|
const B = py - y1
|
||||||
|
const C = x2 - x1
|
||||||
|
const D = y2 - y1
|
||||||
|
|
||||||
|
const dot = A * C + B * D
|
||||||
|
const lenSq = C * C + D * D
|
||||||
|
let param = -1
|
||||||
|
|
||||||
|
if (lenSq !== 0) {
|
||||||
|
param = dot / lenSq
|
||||||
|
}
|
||||||
|
|
||||||
|
let xx, yy
|
||||||
|
|
||||||
|
if (param < 0) {
|
||||||
|
xx = x1
|
||||||
|
yy = y1
|
||||||
|
} else if (param > 1) {
|
||||||
|
xx = x2
|
||||||
|
yy = y2
|
||||||
|
} else {
|
||||||
|
xx = x1 + param * C
|
||||||
|
yy = y1 + param * D
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = px - xx
|
||||||
|
const dy = py - yy
|
||||||
|
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new allocation
|
||||||
|
const createAllocation = (sourceId: string, targetId: string) => {
|
||||||
|
const newAllocation: Allocation = {
|
||||||
|
id: `alloc_${Date.now()}`,
|
||||||
|
sourceAccountId: sourceId,
|
||||||
|
targetAccountId: targetId,
|
||||||
|
percentage: 0.5, // Default 50%
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add allocation and normalize
|
||||||
|
const updatedAllocations = [...network.allocations, newAllocation]
|
||||||
|
const sourceAllocations = updatedAllocations.filter(a => a.sourceAccountId === sourceId)
|
||||||
|
const normalized = normalizeAllocations(sourceAllocations)
|
||||||
|
|
||||||
|
// Replace source allocations with normalized ones
|
||||||
|
const finalAllocations = updatedAllocations.map(a => {
|
||||||
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
||||||
|
return normalizedVersion || a
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedNetwork = calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
allocations: finalAllocations,
|
||||||
|
})
|
||||||
|
|
||||||
|
setNetwork(updatedNetwork)
|
||||||
|
setSelectedAllocationId(newAllocation.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize all allocations from the same source
|
||||||
|
const sourceAllocations = updatedAllocations.filter(
|
||||||
|
a => a.sourceAccountId === allocation.sourceAccountId
|
||||||
|
)
|
||||||
|
const normalized = normalizeAllocations(sourceAllocations)
|
||||||
|
|
||||||
|
// Replace source allocations with normalized ones
|
||||||
|
const finalAllocations = updatedAllocations.map(a => {
|
||||||
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
||||||
|
return normalizedVersion || a
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedNetwork = calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
allocations: finalAllocations,
|
||||||
|
})
|
||||||
|
|
||||||
|
setNetwork(updatedNetwork)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Normalize remaining allocations from the same source
|
||||||
|
const sourceAllocations = updatedAllocations.filter(
|
||||||
|
a => a.sourceAccountId === allocation.sourceAccountId
|
||||||
|
)
|
||||||
|
const normalized = normalizeAllocations(sourceAllocations)
|
||||||
|
|
||||||
|
// Replace source allocations with normalized ones
|
||||||
|
const finalAllocations = updatedAllocations.map(a => {
|
||||||
|
const normalizedVersion = normalized.find(n => n.id === a.id)
|
||||||
|
return normalizedVersion || a
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedNetwork = calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
allocations: finalAllocations,
|
||||||
|
})
|
||||||
|
|
||||||
|
setNetwork(updatedNetwork)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load different network
|
||||||
|
const handleLoadNetwork = (key: string) => {
|
||||||
|
setSelectedNetworkKey(key)
|
||||||
|
const newNetwork = getSampleNetwork(key as keyof typeof sampleNetworks)
|
||||||
|
setNetwork(newNetwork)
|
||||||
|
setSelectedAccountId(null)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
setAllocationSourceId(null)
|
||||||
|
setTool('select')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add funding to network
|
||||||
|
const handleAddFunding = () => {
|
||||||
|
if (fundingAmount <= 0) {
|
||||||
|
console.warn('⚠️ Funding amount must be positive')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeNetwork = network
|
||||||
|
const afterNetwork = initialDistribution(network, fundingAmount)
|
||||||
|
const summary = getDistributionSummary(beforeNetwork, afterNetwork)
|
||||||
|
|
||||||
|
setNetwork(afterNetwork)
|
||||||
|
setLastDistribution(summary)
|
||||||
|
|
||||||
|
console.log(`\n✅ Distribution Complete`)
|
||||||
|
console.log(`Total distributed: ${summary.totalDistributed.toFixed(0)}`)
|
||||||
|
console.log(`Accounts changed: ${summary.accountsChanged}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected account/allocation details
|
||||||
|
const selectedAccount = selectedAccountId
|
||||||
|
? network.accounts.find((a) => a.id === selectedAccountId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const selectedAllocation = selectedAllocationId
|
||||||
|
? network.allocations.find((a) => a.id === selectedAllocationId)
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Get allocations from selected account
|
||||||
|
const outgoingAllocations = selectedAccount
|
||||||
|
? network.allocations.filter(a => a.sourceAccountId === selectedAccount.id)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Get allocations from selected allocation's source (for checking if single)
|
||||||
|
const selectedAllocationSiblings = selectedAllocation
|
||||||
|
? network.allocations.filter(a => a.sourceAccountId === selectedAllocation.sourceAccountId)
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setTool('select')
|
||||||
|
setAllocationSourceId(null)
|
||||||
|
setSelectedAccountId(null)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
} else if (e.key === 'Delete' && selectedAllocationId) {
|
||||||
|
deleteAllocation(selectedAllocationId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Threshold-Based Flow Funding
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Milestone 3: Initial Distribution
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/" className="text-cyan-400 hover:text-cyan-300 transition-colors">
|
||||||
|
← Back to Home
|
||||||
|
</Link>
|
||||||
|
</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'
|
||||||
|
: isDragging
|
||||||
|
? 'cursor-grabbing'
|
||||||
|
: 'cursor-grab'
|
||||||
|
}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 account to create allocation
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
{networkOptions.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">Accounts:</span>
|
||||||
|
<span className="text-white">{network.accounts.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-slate-400">Total Funds:</span>
|
||||||
|
<span className="text-white">{formatCurrency(network.totalFunds)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-400">Shortfall:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(network.totalShortfall)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-yellow-400">Capacity:</span>
|
||||||
|
<span className="text-yellow-400">{formatCurrency(network.totalCapacity)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-green-400">Overflow:</span>
|
||||||
|
<span className="text-green-400">{formatCurrency(network.totalOverflow)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Funding Controls */}
|
||||||
|
<div className="bg-green-900/30 border border-green-500/30 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-green-400 mb-3">💰 Add Funding</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-400 block mb-1">
|
||||||
|
Funding Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={fundingAmount}
|
||||||
|
onChange={(e) => setFundingAmount(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-700 rounded text-sm"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddFunding}
|
||||||
|
className="w-full px-4 py-2 bg-green-600 hover:bg-green-700 rounded text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Distribute Funding
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution Summary */}
|
||||||
|
{lastDistribution && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-green-500/30">
|
||||||
|
<div className="text-xs space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Distributed:</span>
|
||||||
|
<span className="text-green-400 font-medium">
|
||||||
|
{formatCurrency(lastDistribution.totalDistributed)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Accounts Changed:</span>
|
||||||
|
<span className="text-white">{lastDistribution.accountsChanged}</span>
|
||||||
|
</div>
|
||||||
|
{lastDistribution.changes.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
<div className="text-slate-400 text-[10px] mb-1">Changes:</div>
|
||||||
|
{lastDistribution.changes.map((change) => (
|
||||||
|
<div
|
||||||
|
key={change.accountId}
|
||||||
|
className="flex justify-between items-center bg-slate-800/50 p-1.5 rounded"
|
||||||
|
>
|
||||||
|
<span className="text-white text-[11px]">{change.name}</span>
|
||||||
|
<span className="text-green-400 text-[11px] font-mono">
|
||||||
|
+{formatCurrency(change.delta)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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.accounts.find(a => a.id === selectedAllocation.sourceAccountId)?.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">To: </span>
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{network.accounts.find(a => a.id === selectedAllocation.targetAccountId)?.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%. Create additional allocations to split overflow.
|
||||||
|
</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 className="text-[10px] text-slate-500 mt-1">
|
||||||
|
Note: Percentages auto-normalize with other allocations from same source
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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 Account Details */}
|
||||||
|
{selectedAccount && (
|
||||||
|
<div className="bg-slate-700 p-4 rounded">
|
||||||
|
<h3 className="font-semibold text-cyan-400 mb-3">Account Details</h3>
|
||||||
|
<div className="text-xs space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">Name: </span>
|
||||||
|
<span className="text-white font-medium">{selectedAccount.name}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400">Status: </span>
|
||||||
|
<span className={`font-medium ${getStatusColorClass(selectedAccount.status)}`}>
|
||||||
|
{selectedAccount.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Balance:</span>
|
||||||
|
<span className="text-white font-mono">
|
||||||
|
{formatCurrency(selectedAccount.balance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Min Threshold:</span>
|
||||||
|
<span className="text-white font-mono">
|
||||||
|
{formatCurrency(selectedAccount.minThreshold)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-slate-400">Max Threshold:</span>
|
||||||
|
<span className="text-white font-mono">
|
||||||
|
{formatCurrency(selectedAccount.maxThreshold)}
|
||||||
|
</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.accounts.find(a => a.id === alloc.targetAccountId)
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Account List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-400">All Accounts</h3>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{network.accounts.map((acc) => (
|
||||||
|
<button
|
||||||
|
key={acc.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAccountId(acc.id)
|
||||||
|
setSelectedAllocationId(null)
|
||||||
|
}}
|
||||||
|
className={`w-full p-2 rounded text-left transition-colors ${
|
||||||
|
selectedAccountId === acc.id
|
||||||
|
? 'bg-cyan-600 text-white'
|
||||||
|
: 'bg-slate-700 hover:bg-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">{acc.name}</span>
|
||||||
|
<span className={`font-mono ${getStatusColorClass(acc.status)}`}>
|
||||||
|
{formatCurrency(acc.balance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400 text-[10px] mt-1">
|
||||||
|
{acc.status.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>Deficit - Below minimum threshold</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||||
|
<span>Minimum - At minimum threshold</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||||
|
<span>Healthy - Between thresholds</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||||
|
<span>Overflow - Above maximum threshold</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">Milestone 3:</strong> Initial Distribution
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 list-disc list-inside">
|
||||||
|
<li><strong className="text-green-400">Add funding</strong> to distribute across accounts</li>
|
||||||
|
<li><strong className="text-cyan-400">Drag</strong> accounts to reposition them</li>
|
||||||
|
<li>Use <strong className="text-cyan-400">Create Arrow</strong> tool to draw allocations</li>
|
||||||
|
<li>Click arrow to edit percentage</li>
|
||||||
|
<li>Press <kbd className="px-1 bg-slate-800 rounded">Delete</kbd> to remove allocation</li>
|
||||||
|
<li>Check console for distribution logs</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -34,12 +34,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">
|
||||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
View Interactive Demos
|
||||||
|
<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,464 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding Algorithm Engine
|
||||||
|
*
|
||||||
|
* Implements the threshold-based flow funding mechanism as specified in
|
||||||
|
* threshold-based-flow-funding.md
|
||||||
|
*
|
||||||
|
* Algorithm phases:
|
||||||
|
* 1. Initial Distribution: Prioritize minimum thresholds, then fill capacity
|
||||||
|
* 2. Overflow Calculation: Identify funds exceeding maximum thresholds
|
||||||
|
* 3. Overflow Redistribution: Redistribute overflow according to allocations
|
||||||
|
* 4. Recursive Processing: Repeat until convergence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Account,
|
||||||
|
DistributionResult,
|
||||||
|
IterationResult,
|
||||||
|
ValidationResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the distribution algorithm
|
||||||
|
*/
|
||||||
|
export interface DistributionConfig {
|
||||||
|
/** Maximum iterations before stopping (default: 100) */
|
||||||
|
maxIterations?: number
|
||||||
|
/** Convergence threshold - stop when total overflow < epsilon (default: 0.01) */
|
||||||
|
epsilon?: number
|
||||||
|
/** Enable detailed logging (default: false) */
|
||||||
|
verbose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Required<DistributionConfig> = {
|
||||||
|
maxIterations: 100,
|
||||||
|
epsilon: 0.01,
|
||||||
|
verbose: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a flow funding network
|
||||||
|
*/
|
||||||
|
export function validateNetwork(accounts: Account[]): ValidationResult {
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
errors.push('Network must contain at least one account')
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountIds = new Set(accounts.map(a => a.id))
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
// Check threshold validity
|
||||||
|
if (account.minThreshold < 0) {
|
||||||
|
errors.push(`Account ${account.id}: minimum threshold must be non-negative`)
|
||||||
|
}
|
||||||
|
if (account.maxThreshold < 0) {
|
||||||
|
errors.push(`Account ${account.id}: maximum threshold must be non-negative`)
|
||||||
|
}
|
||||||
|
if (account.minThreshold > account.maxThreshold) {
|
||||||
|
errors.push(
|
||||||
|
`Account ${account.id}: minimum threshold (${account.minThreshold}) ` +
|
||||||
|
`exceeds maximum threshold (${account.maxThreshold})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check balance validity
|
||||||
|
if (account.balance < 0) {
|
||||||
|
errors.push(`Account ${account.id}: balance must be non-negative`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allocations
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const [targetId, percentage] of account.allocations.entries()) {
|
||||||
|
if (percentage < 0 || percentage > 100) {
|
||||||
|
errors.push(
|
||||||
|
`Account ${account.id}: allocation to ${targetId} must be between 0 and 100`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!accountIds.has(targetId)) {
|
||||||
|
errors.push(
|
||||||
|
`Account ${account.id}: allocation target ${targetId} does not exist`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (targetId === account.id) {
|
||||||
|
errors.push(`Account ${account.id}: cannot allocate to itself`)
|
||||||
|
}
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation > 100.01) { // Allow small floating point error
|
||||||
|
errors.push(
|
||||||
|
`Account ${account.id}: total allocations (${totalAllocation}%) exceed 100%`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings
|
||||||
|
if (account.allocations.size === 0 && accounts.length > 1) {
|
||||||
|
warnings.push(
|
||||||
|
`Account ${account.id}: has no outgoing allocations (overflow will be lost)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIncoming = accounts.some(a =>
|
||||||
|
Array.from(a.allocations.keys()).includes(account.id)
|
||||||
|
)
|
||||||
|
if (!hasIncoming && account.balance === 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Account ${account.id}: has no incoming allocations and zero balance ` +
|
||||||
|
`(will never receive funds)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Initial Distribution
|
||||||
|
*
|
||||||
|
* Distributes external funding, prioritizing minimum thresholds
|
||||||
|
* then filling remaining capacity up to maximum thresholds
|
||||||
|
*/
|
||||||
|
function distributeInitial(
|
||||||
|
accounts: Account[],
|
||||||
|
funding: number,
|
||||||
|
verbose: boolean
|
||||||
|
): void {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\n=== Initial Distribution: $${funding.toFixed(2)} ===`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total minimum requirement
|
||||||
|
let totalMinRequired = 0
|
||||||
|
const minShortfalls = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const shortfall = Math.max(0, account.minThreshold - account.balance)
|
||||||
|
if (shortfall > 0) {
|
||||||
|
minShortfalls.set(account.id, shortfall)
|
||||||
|
totalMinRequired += shortfall
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`Total minimum requirement: $${totalMinRequired.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 1: Insufficient funds to meet all minimums
|
||||||
|
if (funding < totalMinRequired) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Insufficient funds - distributing proportionally to minimums')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const shortfall = minShortfalls.get(account.id) || 0
|
||||||
|
if (shortfall > 0) {
|
||||||
|
const allocation = (shortfall / totalMinRequired) * funding
|
||||||
|
account.balance += allocation
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` ${account.id}: +$${allocation.toFixed(2)} ` +
|
||||||
|
`(${((shortfall / totalMinRequired) * 100).toFixed(1)}% of funding)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Can meet all minimums
|
||||||
|
if (verbose) {
|
||||||
|
console.log('Sufficient funds - meeting all minimums first')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Fill all minimums
|
||||||
|
for (const account of accounts) {
|
||||||
|
const shortfall = minShortfalls.get(account.id) || 0
|
||||||
|
if (shortfall > 0) {
|
||||||
|
account.balance = account.minThreshold
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${account.id}: filled to minimum ($${account.minThreshold.toFixed(2)})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Distribute remaining funds based on capacity
|
||||||
|
const remaining = funding - totalMinRequired
|
||||||
|
if (remaining <= 0) return
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\nDistributing remaining $${remaining.toFixed(2)} based on capacity`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total remaining capacity
|
||||||
|
let totalCapacity = 0
|
||||||
|
const capacities = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const capacity = Math.max(0, account.maxThreshold - account.balance)
|
||||||
|
if (capacity > 0) {
|
||||||
|
capacities.set(account.id, capacity)
|
||||||
|
totalCapacity += capacity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCapacity === 0) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log('No remaining capacity - all accounts at maximum')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute proportionally to capacity
|
||||||
|
for (const account of accounts) {
|
||||||
|
const capacity = capacities.get(account.id) || 0
|
||||||
|
if (capacity > 0) {
|
||||||
|
const allocation = (capacity / totalCapacity) * remaining
|
||||||
|
account.balance += allocation
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` ${account.id}: +$${allocation.toFixed(2)} ` +
|
||||||
|
`(${((capacity / totalCapacity) * 100).toFixed(1)}% of remaining)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Calculate Overflow
|
||||||
|
*
|
||||||
|
* Identifies funds exceeding maximum thresholds
|
||||||
|
* Returns overflow amounts and adjusts balances
|
||||||
|
*/
|
||||||
|
function calculateOverflow(
|
||||||
|
accounts: Account[],
|
||||||
|
verbose: boolean
|
||||||
|
): Map<string, number> {
|
||||||
|
const overflows = new Map<string, number>()
|
||||||
|
let totalOverflow = 0
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const overflow = Math.max(0, account.balance - account.maxThreshold)
|
||||||
|
if (overflow > 0) {
|
||||||
|
overflows.set(account.id, overflow)
|
||||||
|
totalOverflow += overflow
|
||||||
|
// Adjust balance to maximum
|
||||||
|
account.balance = account.maxThreshold
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${account.id}: overflow $${overflow.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose && totalOverflow > 0) {
|
||||||
|
console.log(`Total overflow: $${totalOverflow.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return overflows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3: Redistribute Overflow
|
||||||
|
*
|
||||||
|
* Redistributes overflow according to allocation preferences
|
||||||
|
* Returns true if any redistribution occurred
|
||||||
|
*/
|
||||||
|
function redistributeOverflow(
|
||||||
|
accounts: Account[],
|
||||||
|
overflows: Map<string, number>,
|
||||||
|
verbose: boolean
|
||||||
|
): Map<string, number> {
|
||||||
|
const accountMap = new Map(accounts.map(a => [a.id, a]))
|
||||||
|
const flows = new Map<string, number>()
|
||||||
|
|
||||||
|
if (verbose && overflows.size > 0) {
|
||||||
|
console.log('\n Redistributing overflow:')
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sourceId, overflow] of overflows.entries()) {
|
||||||
|
const source = accountMap.get(sourceId)
|
||||||
|
if (!source) continue
|
||||||
|
|
||||||
|
// Normalize allocations (should sum to ≤100%)
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const percentage of source.allocations.values()) {
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation === 0) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${sourceId}: no allocations - overflow lost`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute overflow according to allocations
|
||||||
|
for (const [targetId, percentage] of source.allocations.entries()) {
|
||||||
|
const target = accountMap.get(targetId)
|
||||||
|
if (!target) continue
|
||||||
|
|
||||||
|
const normalizedPercentage = percentage / totalAllocation
|
||||||
|
const amount = overflow * normalizedPercentage
|
||||||
|
|
||||||
|
target.balance += amount
|
||||||
|
flows.set(`${sourceId}->${targetId}`, amount)
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` ${sourceId} → ${targetId}: $${amount.toFixed(2)} ` +
|
||||||
|
`(${percentage}% of overflow)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flows
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main distribution function
|
||||||
|
*
|
||||||
|
* Runs the complete flow funding algorithm:
|
||||||
|
* 1. Initial distribution
|
||||||
|
* 2. Iterative overflow redistribution until convergence
|
||||||
|
*/
|
||||||
|
export function runDistribution(
|
||||||
|
accounts: Account[],
|
||||||
|
funding: number,
|
||||||
|
config: DistributionConfig = {}
|
||||||
|
): DistributionResult {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
const { maxIterations, epsilon, verbose } = cfg
|
||||||
|
|
||||||
|
// Validate network
|
||||||
|
const validation = validateNetwork(accounts)
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid network:\n${validation.errors.join('\n')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose && validation.warnings.length > 0) {
|
||||||
|
console.log('⚠️ Warnings:')
|
||||||
|
validation.warnings.forEach(w => console.log(` ${w}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store initial state
|
||||||
|
const initialBalances = new Map(
|
||||||
|
accounts.map(a => [a.id, a.balance])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n📊 Initial State:')
|
||||||
|
accounts.forEach(a => {
|
||||||
|
console.log(
|
||||||
|
` ${a.id}: $${a.balance.toFixed(2)} ` +
|
||||||
|
`(min: $${a.minThreshold.toFixed(2)}, max: $${a.maxThreshold.toFixed(2)})`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Initial distribution
|
||||||
|
distributeInitial(accounts, funding, verbose)
|
||||||
|
|
||||||
|
// Phase 2-4: Iterative overflow redistribution
|
||||||
|
const iterations: IterationResult[] = []
|
||||||
|
let converged = false
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\n--- Iteration ${i} ---`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overflow
|
||||||
|
const overflows = calculateOverflow(accounts, verbose)
|
||||||
|
const totalOverflow = Array.from(overflows.values()).reduce(
|
||||||
|
(sum, o) => sum + o,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record iteration state
|
||||||
|
const iteration: IterationResult = {
|
||||||
|
iteration: i,
|
||||||
|
balances: new Map(accounts.map(a => [a.id, a.balance])),
|
||||||
|
overflows,
|
||||||
|
totalOverflow,
|
||||||
|
flows: new Map(),
|
||||||
|
converged: totalOverflow < epsilon,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check convergence
|
||||||
|
if (totalOverflow < epsilon) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`✓ Converged (overflow < ${epsilon})`)
|
||||||
|
}
|
||||||
|
converged = true
|
||||||
|
iterations.push(iteration)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redistribute overflow
|
||||||
|
const flows = redistributeOverflow(accounts, overflows, verbose)
|
||||||
|
iteration.flows = flows
|
||||||
|
|
||||||
|
iterations.push(iteration)
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n Balances after redistribution:')
|
||||||
|
accounts.forEach(a => {
|
||||||
|
console.log(` ${a.id}: $${a.balance.toFixed(2)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!converged && verbose) {
|
||||||
|
console.log(`\n⚠️ Did not converge within ${maxIterations} iterations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final state
|
||||||
|
const finalBalances = new Map(
|
||||||
|
accounts.map(a => [a.id, a.balance])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n🎯 Final State:')
|
||||||
|
accounts.forEach(a => {
|
||||||
|
const initial = initialBalances.get(a.id) || 0
|
||||||
|
const change = a.balance - initial
|
||||||
|
console.log(
|
||||||
|
` ${a.id}: $${a.balance.toFixed(2)} ` +
|
||||||
|
`(${change >= 0 ? '+' : ''}$${change.toFixed(2)})`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialBalances,
|
||||||
|
finalBalances,
|
||||||
|
iterations,
|
||||||
|
converged,
|
||||||
|
totalFunding: funding,
|
||||||
|
iterationCount: iterations.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Create a deep copy of accounts for simulation
|
||||||
|
*/
|
||||||
|
export function cloneAccounts(accounts: Account[]): Account[] {
|
||||||
|
return accounts.map(a => ({
|
||||||
|
...a,
|
||||||
|
allocations: new Map(a.allocations),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
/**
|
||||||
|
* Preset Flow Funding Scenarios
|
||||||
|
*
|
||||||
|
* Each scenario demonstrates different network topologies and flow patterns
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Account } from './types'
|
||||||
|
|
||||||
|
export interface Scenario {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
accounts: Account[]
|
||||||
|
suggestedFunding: number
|
||||||
|
/** Visual layout positions for rendering (x, y in pixels) */
|
||||||
|
layout: Map<string, { x: number; y: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 1: Linear Chain
|
||||||
|
* A → B → C → D
|
||||||
|
*
|
||||||
|
* Demonstrates simple cascading flow
|
||||||
|
*/
|
||||||
|
export const linearChain: Scenario = {
|
||||||
|
id: 'linear-chain',
|
||||||
|
name: 'Linear Chain',
|
||||||
|
description:
|
||||||
|
'A simple chain showing funds flowing from left to right. ' +
|
||||||
|
'Overflow from each account flows to the next in line.',
|
||||||
|
suggestedFunding: 1000,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 300,
|
||||||
|
allocations: new Map([['B', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 150,
|
||||||
|
maxThreshold: 350,
|
||||||
|
allocations: new Map([['C', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 300,
|
||||||
|
allocations: new Map([['D', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 400,
|
||||||
|
allocations: new Map(), // End of chain
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 100, y: 250 }],
|
||||||
|
['B', { x: 250, y: 250 }],
|
||||||
|
['C', { x: 400, y: 250 }],
|
||||||
|
['D', { x: 550, y: 250 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 2: Mutual Aid Circle
|
||||||
|
* A ↔ B ↔ C ↔ A
|
||||||
|
*
|
||||||
|
* Demonstrates circular solidarity and equilibrium
|
||||||
|
*/
|
||||||
|
export const mutualAidCircle: Scenario = {
|
||||||
|
id: 'mutual-aid-circle',
|
||||||
|
name: 'Mutual Aid Circle',
|
||||||
|
description:
|
||||||
|
'Three people in a circular mutual aid network. Each person allocates their ' +
|
||||||
|
'overflow to help the next person in the circle, creating a self-balancing system.',
|
||||||
|
suggestedFunding: 1500,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 400,
|
||||||
|
allocations: new Map([['B', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 400,
|
||||||
|
allocations: new Map([['C', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 400,
|
||||||
|
allocations: new Map([['A', 100]]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 325, y: 150 }],
|
||||||
|
['B', { x: 475, y: 320 }],
|
||||||
|
['C', { x: 175, y: 320 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 3: Hub and Spoke
|
||||||
|
* Center → {A, B, C, D}
|
||||||
|
*
|
||||||
|
* Demonstrates redistribution from a central fund
|
||||||
|
*/
|
||||||
|
export const hubAndSpoke: Scenario = {
|
||||||
|
id: 'hub-and-spoke',
|
||||||
|
name: 'Hub and Spoke',
|
||||||
|
description:
|
||||||
|
'A central redistribution hub that allocates overflow evenly to four ' +
|
||||||
|
'peripheral accounts. Models a community fund or mutual aid pool.',
|
||||||
|
suggestedFunding: 2000,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 'Hub',
|
||||||
|
name: 'Community Fund',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 300,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 25],
|
||||||
|
['B', 25],
|
||||||
|
['C', 25],
|
||||||
|
['D', 25],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map(), // Could flow back to hub
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 250,
|
||||||
|
maxThreshold: 550,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 150,
|
||||||
|
maxThreshold: 450,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['Hub', { x: 325, y: 250 }],
|
||||||
|
['A', { x: 325, y: 100 }],
|
||||||
|
['B', { x: 525, y: 250 }],
|
||||||
|
['C', { x: 325, y: 400 }],
|
||||||
|
['D', { x: 125, y: 250 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 4: Complex Network
|
||||||
|
* Multi-hop redistribution with various allocation strategies
|
||||||
|
*/
|
||||||
|
export const complexNetwork: Scenario = {
|
||||||
|
id: 'complex-network',
|
||||||
|
name: 'Complex Network',
|
||||||
|
description:
|
||||||
|
'A realistic network with 8 accounts showing various allocation strategies: ' +
|
||||||
|
'some split overflow evenly, others prioritize specific recipients. ' +
|
||||||
|
'Demonstrates emergence of flow patterns.',
|
||||||
|
suggestedFunding: 5000,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 100,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([
|
||||||
|
['B', 50],
|
||||||
|
['C', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 50,
|
||||||
|
minThreshold: 250,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['D', 30],
|
||||||
|
['E', 70],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 450,
|
||||||
|
allocations: new Map([
|
||||||
|
['F', 100],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
balance: 200,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 550,
|
||||||
|
allocations: new Map([
|
||||||
|
['G', 40],
|
||||||
|
['H', 60],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'E',
|
||||||
|
name: 'Eve',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 250,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['F', 50],
|
||||||
|
['G', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'F',
|
||||||
|
name: 'Frank',
|
||||||
|
balance: 150,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 400,
|
||||||
|
allocations: new Map([
|
||||||
|
['H', 100],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'G',
|
||||||
|
name: 'Grace',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 30],
|
||||||
|
['H', 70],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'H',
|
||||||
|
name: 'Henry',
|
||||||
|
balance: 50,
|
||||||
|
minThreshold: 350,
|
||||||
|
maxThreshold: 700,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 20],
|
||||||
|
['E', 80],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 150, y: 150 }],
|
||||||
|
['B', { x: 350, y: 100 }],
|
||||||
|
['C', { x: 350, y: 200 }],
|
||||||
|
['D', { x: 550, y: 150 }],
|
||||||
|
['E', { x: 550, y: 300 }],
|
||||||
|
['F', { x: 350, y: 350 }],
|
||||||
|
['G', { x: 150, y: 350 }],
|
||||||
|
['H', { x: 150, y: 500 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 5: Worker Cooperative
|
||||||
|
* Models a worker coop with shared risk pool
|
||||||
|
*/
|
||||||
|
export const workerCoop: Scenario = {
|
||||||
|
id: 'worker-coop',
|
||||||
|
name: 'Worker Cooperative',
|
||||||
|
description:
|
||||||
|
'Five workers in a cooperative. Each worker\'s overflow goes partly to a shared ' +
|
||||||
|
'risk pool and partly to supporting other workers, creating solidarity and resilience.',
|
||||||
|
suggestedFunding: 3000,
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 'Pool',
|
||||||
|
name: 'Risk Pool',
|
||||||
|
balance: 500,
|
||||||
|
minThreshold: 1000,
|
||||||
|
maxThreshold: 2000,
|
||||||
|
allocations: new Map([
|
||||||
|
['W1', 20],
|
||||||
|
['W2', 20],
|
||||||
|
['W3', 20],
|
||||||
|
['W4', 20],
|
||||||
|
['W5', 20],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W1',
|
||||||
|
name: 'Worker 1',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W2', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W2',
|
||||||
|
name: 'Worker 2',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W3', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W3',
|
||||||
|
name: 'Worker 3',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W4', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W4',
|
||||||
|
name: 'Worker 4',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W5', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W5',
|
||||||
|
name: 'Worker 5',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W1', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['Pool', { x: 325, y: 250 }],
|
||||||
|
['W1', { x: 325, y: 100 }],
|
||||||
|
['W2', { x: 510, y: 175 }],
|
||||||
|
['W3', { x: 510, y: 325 }],
|
||||||
|
['W4', { x: 325, y: 400 }],
|
||||||
|
['W5', { x: 140, y: 325 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available scenarios
|
||||||
|
*/
|
||||||
|
export const ALL_SCENARIOS: Scenario[] = [
|
||||||
|
linearChain,
|
||||||
|
mutualAidCircle,
|
||||||
|
hubAndSpoke,
|
||||||
|
complexNetwork,
|
||||||
|
workerCoop,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scenario by ID
|
||||||
|
*/
|
||||||
|
export function getScenario(id: string): Scenario | undefined {
|
||||||
|
return ALL_SCENARIOS.find(s => s.id === id)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
/**
|
||||||
|
* Targeted Funding - Add money to specific accounts and watch propagation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Account, DistributionResult, IterationResult } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run distribution starting from current account balances
|
||||||
|
* (Skips initial distribution phase - just runs overflow redistribution)
|
||||||
|
*/
|
||||||
|
export function runTargetedDistribution(
|
||||||
|
accounts: Account[],
|
||||||
|
config: {
|
||||||
|
maxIterations?: number
|
||||||
|
epsilon?: number
|
||||||
|
verbose?: boolean
|
||||||
|
} = {}
|
||||||
|
): DistributionResult {
|
||||||
|
const { maxIterations = 100, epsilon = 0.01, verbose = false } = config
|
||||||
|
|
||||||
|
// Store initial state
|
||||||
|
const initialBalances = new Map(accounts.map(a => [a.id, a.balance]))
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n📍 Targeted Distribution (from current balances)')
|
||||||
|
accounts.forEach(a => {
|
||||||
|
console.log(` ${a.id}: $${a.balance.toFixed(2)}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run overflow redistribution iterations
|
||||||
|
const iterations: IterationResult[] = []
|
||||||
|
let converged = false
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\n--- Iteration ${i} ---`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overflow
|
||||||
|
const overflows = new Map<string, number>()
|
||||||
|
let totalOverflow = 0
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const overflow = Math.max(0, account.balance - account.maxThreshold)
|
||||||
|
if (overflow > 0) {
|
||||||
|
overflows.set(account.id, overflow)
|
||||||
|
totalOverflow += overflow
|
||||||
|
// Adjust balance to maximum
|
||||||
|
account.balance = account.maxThreshold
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${account.id}: overflow $${overflow.toFixed(2)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record iteration state
|
||||||
|
const flows = new Map<string, number>()
|
||||||
|
const iteration: IterationResult = {
|
||||||
|
iteration: i,
|
||||||
|
balances: new Map(accounts.map(a => [a.id, a.balance])),
|
||||||
|
overflows,
|
||||||
|
totalOverflow,
|
||||||
|
flows,
|
||||||
|
converged: totalOverflow < epsilon,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check convergence
|
||||||
|
if (totalOverflow < epsilon) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`✓ Converged (overflow < ${epsilon})`)
|
||||||
|
}
|
||||||
|
converged = true
|
||||||
|
iterations.push(iteration)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redistribute overflow
|
||||||
|
const accountMap = new Map(accounts.map(a => [a.id, a]))
|
||||||
|
|
||||||
|
for (const [sourceId, overflow] of overflows.entries()) {
|
||||||
|
const source = accountMap.get(sourceId)
|
||||||
|
if (!source) continue
|
||||||
|
|
||||||
|
// Normalize allocations
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const percentage of source.allocations.values()) {
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation === 0) {
|
||||||
|
if (verbose) {
|
||||||
|
console.log(` ${sourceId}: no allocations - overflow lost`)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute overflow
|
||||||
|
for (const [targetId, percentage] of source.allocations.entries()) {
|
||||||
|
const target = accountMap.get(targetId)
|
||||||
|
if (!target) continue
|
||||||
|
|
||||||
|
const normalizedPercentage = percentage / totalAllocation
|
||||||
|
const amount = overflow * normalizedPercentage
|
||||||
|
|
||||||
|
target.balance += amount
|
||||||
|
flows.set(`${sourceId}->${targetId}`, amount)
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(
|
||||||
|
` ${sourceId} → ${targetId}: $${amount.toFixed(2)} (${percentage}%)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iteration.flows = flows
|
||||||
|
iterations.push(iteration)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!converged && verbose) {
|
||||||
|
console.log(`\n⚠️ Did not converge within ${maxIterations} iterations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalBalances = new Map(accounts.map(a => [a.id, a.balance]))
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n🎯 Final State:')
|
||||||
|
accounts.forEach(a => {
|
||||||
|
const initial = initialBalances.get(a.id) || 0
|
||||||
|
const change = a.balance - initial
|
||||||
|
console.log(
|
||||||
|
` ${a.id}: $${a.balance.toFixed(2)} ` +
|
||||||
|
`(${change >= 0 ? '+' : ''}$${change.toFixed(2)})`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialBalances,
|
||||||
|
finalBalances,
|
||||||
|
iterations,
|
||||||
|
converged,
|
||||||
|
totalFunding: 0, // Not applicable for targeted
|
||||||
|
iterationCount: iterations.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding Core Types
|
||||||
|
* Isolated module for threshold-based flow funding mechanism
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an account in the flow funding network
|
||||||
|
*/
|
||||||
|
export interface Account {
|
||||||
|
id: string
|
||||||
|
/** Display name for the account */
|
||||||
|
name: string
|
||||||
|
/** Current balance */
|
||||||
|
balance: number
|
||||||
|
/** Minimum sustainable funding level */
|
||||||
|
minThreshold: number
|
||||||
|
/** Maximum threshold - beyond this, funds overflow */
|
||||||
|
maxThreshold: number
|
||||||
|
/** Allocation preferences: map of target account ID to percentage (0-100) */
|
||||||
|
allocations: Map<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a single redistribution iteration
|
||||||
|
*/
|
||||||
|
export interface IterationResult {
|
||||||
|
/** Iteration number (0-indexed) */
|
||||||
|
iteration: number
|
||||||
|
/** Account balances after this iteration */
|
||||||
|
balances: Map<string, number>
|
||||||
|
/** Overflow amounts per account */
|
||||||
|
overflows: Map<string, number>
|
||||||
|
/** Total overflow in the system */
|
||||||
|
totalOverflow: number
|
||||||
|
/** Flows from account to account (sourceId-targetId -> amount) */
|
||||||
|
flows: Map<string, number>
|
||||||
|
/** Whether the system converged in this iteration */
|
||||||
|
converged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete result of running the flow funding distribution
|
||||||
|
*/
|
||||||
|
export interface DistributionResult {
|
||||||
|
/** Initial state before distribution */
|
||||||
|
initialBalances: Map<string, number>
|
||||||
|
/** Final balances after convergence */
|
||||||
|
finalBalances: Map<string, number>
|
||||||
|
/** History of each iteration */
|
||||||
|
iterations: IterationResult[]
|
||||||
|
/** Whether the distribution converged */
|
||||||
|
converged: boolean
|
||||||
|
/** Total external funding added */
|
||||||
|
totalFunding: number
|
||||||
|
/** Number of iterations to convergence */
|
||||||
|
iterationCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account state for threshold visualization
|
||||||
|
*/
|
||||||
|
export type AccountState =
|
||||||
|
| 'below-minimum' // balance < minThreshold (red)
|
||||||
|
| 'sustainable' // minThreshold <= balance < maxThreshold (yellow)
|
||||||
|
| 'at-maximum' // balance >= maxThreshold (green)
|
||||||
|
| 'overflowing' // balance > maxThreshold in current iteration (blue)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to determine account state
|
||||||
|
*/
|
||||||
|
export function getAccountState(
|
||||||
|
balance: number,
|
||||||
|
minThreshold: number,
|
||||||
|
maxThreshold: number,
|
||||||
|
hasOverflow: boolean
|
||||||
|
): AccountState {
|
||||||
|
if (hasOverflow) return 'overflowing'
|
||||||
|
if (balance >= maxThreshold) return 'at-maximum'
|
||||||
|
if (balance >= minThreshold) return 'sustainable'
|
||||||
|
return 'below-minimum'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result for a flow funding network
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,455 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding V2 Engine - Continuous Flow Dynamics
|
||||||
|
*
|
||||||
|
* Implements progressive outflow zones with steady-state equilibrium
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
FlowNode,
|
||||||
|
FlowEdge,
|
||||||
|
FlowNetwork,
|
||||||
|
FlowZone,
|
||||||
|
OverflowNode,
|
||||||
|
ValidationResult,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time conversion constants
|
||||||
|
*/
|
||||||
|
const SECONDS_PER_MONTH = 30 * 24 * 60 * 60 // ~2.592M seconds
|
||||||
|
const MONTHS_PER_SECOND = 1 / SECONDS_PER_MONTH
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for flow simulation
|
||||||
|
*/
|
||||||
|
export interface FlowConfig {
|
||||||
|
maxIterations?: number
|
||||||
|
epsilon?: number // Convergence threshold
|
||||||
|
verbose?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Required<FlowConfig> = {
|
||||||
|
maxIterations: 1000,
|
||||||
|
epsilon: 0.001, // $0.001/month
|
||||||
|
verbose: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert $/month to $/second for internal calculation
|
||||||
|
*/
|
||||||
|
export function perMonthToPerSecond(amountPerMonth: number): number {
|
||||||
|
return amountPerMonth * MONTHS_PER_SECOND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert $/second to $/month for UI display
|
||||||
|
*/
|
||||||
|
export function perSecondToPerMonth(amountPerSecond: number): number {
|
||||||
|
return amountPerSecond / MONTHS_PER_SECOND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine which zone a node is in based on total inflow
|
||||||
|
*
|
||||||
|
* Capacity threshold is 1.5x the max threshold
|
||||||
|
*/
|
||||||
|
export function getFlowZone(node: FlowNode): FlowZone {
|
||||||
|
const totalInflow = node.totalInflow || 0
|
||||||
|
const capacityThreshold = 1.5 * node.maxThreshold
|
||||||
|
|
||||||
|
if (totalInflow < node.minThreshold) {
|
||||||
|
return 'deficit'
|
||||||
|
} else if (totalInflow < capacityThreshold) {
|
||||||
|
return 'building'
|
||||||
|
} else {
|
||||||
|
return 'capacity'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate progressive outflow based on zone
|
||||||
|
*
|
||||||
|
* Deficit Zone (inflow < min):
|
||||||
|
* outflow = 0 (keep everything)
|
||||||
|
*
|
||||||
|
* Building Zone (min ≤ inflow < 1.5 * max):
|
||||||
|
* outflow = (inflow - min) × (0.5 × max) / (1.5 × max - min)
|
||||||
|
* Progressive sharing that smoothly increases
|
||||||
|
*
|
||||||
|
* Capacity Zone (inflow ≥ 1.5 * max):
|
||||||
|
* outflow = inflow - max
|
||||||
|
* Retain max, share all excess
|
||||||
|
*
|
||||||
|
* This ensures monotonically increasing outflow and smooth transitions.
|
||||||
|
*/
|
||||||
|
export function calculateOutflow(node: FlowNode): number {
|
||||||
|
const totalInflow = node.totalInflow || 0
|
||||||
|
const { minThreshold, maxThreshold } = node
|
||||||
|
|
||||||
|
// Capacity threshold: when you start sharing all excess above max
|
||||||
|
const capacityThreshold = 1.5 * maxThreshold
|
||||||
|
|
||||||
|
// Deficit zone: keep everything
|
||||||
|
if (totalInflow < minThreshold) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capacity zone: retain max, share all excess
|
||||||
|
if (totalInflow >= capacityThreshold) {
|
||||||
|
return totalInflow - maxThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building zone: progressive sharing
|
||||||
|
const buildingRange = capacityThreshold - minThreshold
|
||||||
|
if (buildingRange === 0) {
|
||||||
|
// Edge case: min === 1.5 * max (shouldn't happen in practice)
|
||||||
|
return totalInflow - maxThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
const excess = totalInflow - minThreshold
|
||||||
|
const targetOutflow = 0.5 * maxThreshold // What we'll share at capacity threshold
|
||||||
|
|
||||||
|
return (excess / buildingRange) * targetOutflow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate network structure
|
||||||
|
*/
|
||||||
|
export function validateNetwork(nodes: FlowNode[]): ValidationResult {
|
||||||
|
const errors: string[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
errors.push('Network must contain at least one node')
|
||||||
|
return { valid: false, errors, warnings }
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id))
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Check thresholds
|
||||||
|
if (node.minThreshold < 0) {
|
||||||
|
errors.push(`Node ${node.id}: min threshold must be non-negative`)
|
||||||
|
}
|
||||||
|
if (node.maxThreshold < 0) {
|
||||||
|
errors.push(`Node ${node.id}: max threshold must be non-negative`)
|
||||||
|
}
|
||||||
|
if (node.minThreshold > node.maxThreshold) {
|
||||||
|
errors.push(
|
||||||
|
`Node ${node.id}: min threshold (${node.minThreshold}) ` +
|
||||||
|
`exceeds max threshold (${node.maxThreshold})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check external inflow
|
||||||
|
if (node.externalInflow < 0) {
|
||||||
|
errors.push(`Node ${node.id}: external inflow must be non-negative`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allocations
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const [targetId, percentage] of node.allocations.entries()) {
|
||||||
|
if (percentage < 0 || percentage > 100) {
|
||||||
|
errors.push(
|
||||||
|
`Node ${node.id}: allocation to ${targetId} must be 0-100`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!nodeIds.has(targetId)) {
|
||||||
|
errors.push(
|
||||||
|
`Node ${node.id}: allocation target ${targetId} does not exist`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (targetId === node.id) {
|
||||||
|
errors.push(`Node ${node.id}: cannot allocate to itself`)
|
||||||
|
}
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation > 100.01) {
|
||||||
|
errors.push(
|
||||||
|
`Node ${node.id}: total allocations (${totalAllocation}%) exceed 100%`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnings
|
||||||
|
if (node.allocations.size === 0 && nodes.length > 1) {
|
||||||
|
warnings.push(
|
||||||
|
`Node ${node.id}: no outgoing allocations (overflow will be lost)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate steady-state flow equilibrium
|
||||||
|
*
|
||||||
|
* Uses iterative convergence to find stable flow rates where
|
||||||
|
* each node's inflow equals external inflow + allocations from other nodes
|
||||||
|
*/
|
||||||
|
export function calculateSteadyState(
|
||||||
|
nodes: FlowNode[],
|
||||||
|
config: FlowConfig = {}
|
||||||
|
): FlowNetwork {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
const { maxIterations, epsilon, verbose } = cfg
|
||||||
|
|
||||||
|
// Validate network
|
||||||
|
const validation = validateNetwork(nodes)
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid network:\n${validation.errors.join('\n')}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose && validation.warnings.length > 0) {
|
||||||
|
console.log('⚠️ Warnings:')
|
||||||
|
validation.warnings.forEach(w => console.log(` ${w}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create node map
|
||||||
|
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||||
|
|
||||||
|
// Initialize total inflows with external inflows
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.totalInflow = node.externalInflow
|
||||||
|
node.totalOutflow = 0
|
||||||
|
node.balance = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n🌊 Starting Steady-State Calculation')
|
||||||
|
console.log('Initial state:')
|
||||||
|
nodes.forEach(n => {
|
||||||
|
console.log(
|
||||||
|
` ${n.id}: external=$${n.externalInflow}/mo ` +
|
||||||
|
`(min=$${n.minThreshold}, max=$${n.maxThreshold})`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterative convergence
|
||||||
|
let converged = false
|
||||||
|
let iterations = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
iterations++
|
||||||
|
|
||||||
|
// Calculate outflows for each node
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.totalOutflow = calculateOutflow(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new inflows based on allocations
|
||||||
|
const newInflows = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Start with external inflow
|
||||||
|
newInflows.set(node.id, node.externalInflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add allocated flows
|
||||||
|
for (const source of nodes) {
|
||||||
|
const outflow = source.totalOutflow || 0
|
||||||
|
|
||||||
|
if (outflow > 0) {
|
||||||
|
// Normalize allocations
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const percentage of source.allocations.values()) {
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation > 0) {
|
||||||
|
for (const [targetId, percentage] of source.allocations.entries()) {
|
||||||
|
const target = nodeMap.get(targetId)
|
||||||
|
if (!target) continue
|
||||||
|
|
||||||
|
const normalizedPercentage = percentage / totalAllocation
|
||||||
|
const flowAmount = outflow * normalizedPercentage
|
||||||
|
|
||||||
|
const currentInflow = newInflows.get(targetId) || 0
|
||||||
|
newInflows.set(targetId, currentInflow + flowAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check convergence
|
||||||
|
let maxChange = 0
|
||||||
|
for (const node of nodes) {
|
||||||
|
const newInflow = newInflows.get(node.id) || 0
|
||||||
|
const oldInflow = node.totalInflow || 0
|
||||||
|
const change = Math.abs(newInflow - oldInflow)
|
||||||
|
maxChange = Math.max(maxChange, change)
|
||||||
|
|
||||||
|
node.totalInflow = newInflow
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose && i < 5) {
|
||||||
|
console.log(`\nIteration ${i}:`)
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const zone = getFlowZone(n)
|
||||||
|
console.log(
|
||||||
|
` ${n.id}: in=$${(n.totalInflow || 0).toFixed(2)}/mo ` +
|
||||||
|
`out=$${(n.totalOutflow || 0).toFixed(2)}/mo [${zone}]`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
console.log(` Max change: $${maxChange.toFixed(4)}/mo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxChange < epsilon) {
|
||||||
|
converged = true
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`\n✓ Converged after ${iterations} iterations`)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!converged && verbose) {
|
||||||
|
console.log(`\n⚠️ Did not converge within ${maxIterations} iterations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate edges
|
||||||
|
const edges: FlowEdge[] = []
|
||||||
|
|
||||||
|
for (const source of nodes) {
|
||||||
|
const outflow = source.totalOutflow || 0
|
||||||
|
|
||||||
|
if (outflow > 0) {
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const percentage of source.allocations.values()) {
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalAllocation > 0) {
|
||||||
|
for (const [targetId, percentage] of source.allocations.entries()) {
|
||||||
|
const normalizedPercentage = percentage / totalAllocation
|
||||||
|
const flowRate = outflow * normalizedPercentage
|
||||||
|
|
||||||
|
if (flowRate > 0) {
|
||||||
|
edges.push({
|
||||||
|
source: source.id,
|
||||||
|
target: targetId,
|
||||||
|
flowRate,
|
||||||
|
percentage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overflow node
|
||||||
|
const totalExternalInflow = nodes.reduce(
|
||||||
|
(sum, n) => sum + n.externalInflow,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalNetworkCapacity = nodes.reduce(
|
||||||
|
(sum, n) => sum + n.maxThreshold,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalNetworkNeeds = nodes.reduce(
|
||||||
|
(sum, n) => sum + n.minThreshold,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Overflow node appears when unallocated overflow exists
|
||||||
|
let overflowNode: OverflowNode | null = null
|
||||||
|
let totalUnallocatedOverflow = 0
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const outflow = node.totalOutflow || 0
|
||||||
|
|
||||||
|
// Calculate allocated overflow
|
||||||
|
let totalAllocation = 0
|
||||||
|
for (const percentage of node.allocations.values()) {
|
||||||
|
totalAllocation += percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unallocated percentage
|
||||||
|
const unallocatedPercentage = Math.max(0, 100 - totalAllocation)
|
||||||
|
const unallocated = (outflow * unallocatedPercentage) / 100
|
||||||
|
|
||||||
|
totalUnallocatedOverflow += unallocated
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalUnallocatedOverflow > epsilon) {
|
||||||
|
overflowNode = {
|
||||||
|
id: 'overflow',
|
||||||
|
totalInflow: totalUnallocatedOverflow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log('\n📊 Final Network State:')
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const zone = getFlowZone(n)
|
||||||
|
const retention = (n.totalInflow || 0) - (n.totalOutflow || 0)
|
||||||
|
console.log(
|
||||||
|
` ${n.id}: ` +
|
||||||
|
`in=$${(n.totalInflow || 0).toFixed(2)}/mo ` +
|
||||||
|
`out=$${(n.totalOutflow || 0).toFixed(2)}/mo ` +
|
||||||
|
`retain=$${retention.toFixed(2)}/mo ` +
|
||||||
|
`[${zone}]`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overflowNode) {
|
||||||
|
console.log(
|
||||||
|
` Overflow: $${overflowNode.totalInflow.toFixed(2)}/mo (unallocated)`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nNetwork totals:`)
|
||||||
|
console.log(` External inflow: $${totalExternalInflow.toFixed(2)}/mo`)
|
||||||
|
console.log(` Network needs: $${totalNetworkNeeds.toFixed(2)}/mo`)
|
||||||
|
console.log(` Network capacity: $${totalNetworkCapacity.toFixed(2)}/mo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodeMap,
|
||||||
|
edges,
|
||||||
|
overflowNode,
|
||||||
|
totalExternalInflow,
|
||||||
|
totalNetworkCapacity,
|
||||||
|
totalNetworkNeeds,
|
||||||
|
converged,
|
||||||
|
iterations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone nodes for simulation
|
||||||
|
*/
|
||||||
|
export function cloneNodes(nodes: FlowNode[]): FlowNode[] {
|
||||||
|
return nodes.map(n => ({
|
||||||
|
...n,
|
||||||
|
allocations: new Map(n.allocations),
|
||||||
|
totalInflow: n.totalInflow,
|
||||||
|
totalOutflow: n.totalOutflow,
|
||||||
|
balance: n.balance,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update node balances based on flow rates over time
|
||||||
|
* (For visualization - accumulate balance over delta time)
|
||||||
|
*/
|
||||||
|
export function updateBalances(
|
||||||
|
nodes: FlowNode[],
|
||||||
|
deltaSeconds: number
|
||||||
|
): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const inflowPerSecond = perMonthToPerSecond(node.totalInflow || 0)
|
||||||
|
const outflowPerSecond = perMonthToPerSecond(node.totalOutflow || 0)
|
||||||
|
const netFlowPerSecond = inflowPerSecond - outflowPerSecond
|
||||||
|
|
||||||
|
node.balance = (node.balance || 0) + netFlowPerSecond * deltaSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,399 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding V2 - Preset Scenarios
|
||||||
|
*
|
||||||
|
* Demonstrates various network topologies with continuous flow dynamics
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FlowNode, ScenarioV2 } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 1: Linear Chain
|
||||||
|
* A → B → C → D
|
||||||
|
*
|
||||||
|
* Demonstrates cascading progressive flow
|
||||||
|
*/
|
||||||
|
export const linearChainV2: ScenarioV2 = {
|
||||||
|
id: 'linear-chain-v2',
|
||||||
|
name: 'Linear Chain',
|
||||||
|
description:
|
||||||
|
'A simple chain showing progressive flow from left to right. ' +
|
||||||
|
'Watch how funding to A cascades through the network as each node ' +
|
||||||
|
'enters different flow zones.',
|
||||||
|
suggestedTotalInflow: 1200,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
externalInflow: 800,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([['B', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([['C', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([['D', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 400,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 100, y: 300 }],
|
||||||
|
['B', { x: 280, y: 300 }],
|
||||||
|
['C', { x: 460, y: 300 }],
|
||||||
|
['D', { x: 640, y: 300 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 2: Mutual Aid Circle
|
||||||
|
* A ↔ B ↔ C ↔ A
|
||||||
|
*
|
||||||
|
* Demonstrates circular solidarity and dynamic equilibrium
|
||||||
|
*/
|
||||||
|
export const mutualAidCircleV2: ScenarioV2 = {
|
||||||
|
id: 'mutual-aid-circle-v2',
|
||||||
|
name: 'Mutual Aid Circle',
|
||||||
|
description:
|
||||||
|
'Three people in a circular mutual aid network. Each person shares ' +
|
||||||
|
'their overflow with the next person, creating a self-balancing system. ' +
|
||||||
|
'Adjust inflows to see how the network finds equilibrium.',
|
||||||
|
suggestedTotalInflow: 1500,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
externalInflow: 500,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([['B', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
externalInflow: 500,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([['C', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
externalInflow: 500,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([['A', 100]]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 370, y: 150 }],
|
||||||
|
['B', { x: 520, y: 380 }],
|
||||||
|
['C', { x: 220, y: 380 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 3: Hub and Spoke
|
||||||
|
* Center → {A, B, C, D}
|
||||||
|
*
|
||||||
|
* Demonstrates redistribution from a central fund
|
||||||
|
*/
|
||||||
|
export const hubAndSpokeV2: ScenarioV2 = {
|
||||||
|
id: 'hub-and-spoke-v2',
|
||||||
|
name: 'Hub and Spoke',
|
||||||
|
description:
|
||||||
|
'A central redistribution hub that shares overflow evenly to four ' +
|
||||||
|
'peripheral accounts. Models a community fund or mutual aid pool. ' +
|
||||||
|
'Try adjusting the hub\'s external funding.',
|
||||||
|
suggestedTotalInflow: 2000,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'Hub',
|
||||||
|
name: 'Community Fund',
|
||||||
|
externalInflow: 2000,
|
||||||
|
minThreshold: 200,
|
||||||
|
maxThreshold: 500,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 25],
|
||||||
|
['B', 25],
|
||||||
|
['C', 25],
|
||||||
|
['D', 25],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 400,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 1000,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 700,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
externalInflow: 0,
|
||||||
|
minThreshold: 400,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['Hub', { x: 370, y: 300 }],
|
||||||
|
['A', { x: 370, y: 120 }],
|
||||||
|
['B', { x: 580, y: 300 }],
|
||||||
|
['C', { x: 370, y: 480 }],
|
||||||
|
['D', { x: 160, y: 300 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 4: Complex Network
|
||||||
|
* Multi-hop redistribution with various strategies
|
||||||
|
*/
|
||||||
|
export const complexNetworkV2: ScenarioV2 = {
|
||||||
|
id: 'complex-network-v2',
|
||||||
|
name: 'Complex Network',
|
||||||
|
description:
|
||||||
|
'A realistic network with 8 accounts showing various allocation strategies: ' +
|
||||||
|
'some split overflow evenly, others prioritize specific recipients. ' +
|
||||||
|
'Watch emergent flow patterns and steady-state behavior.',
|
||||||
|
suggestedTotalInflow: 5000,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'A',
|
||||||
|
name: 'Alice',
|
||||||
|
externalInflow: 1200,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 1000,
|
||||||
|
allocations: new Map([
|
||||||
|
['B', 50],
|
||||||
|
['C', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'B',
|
||||||
|
name: 'Bob',
|
||||||
|
externalInflow: 800,
|
||||||
|
minThreshold: 400,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['D', 30],
|
||||||
|
['E', 70],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'C',
|
||||||
|
name: 'Carol',
|
||||||
|
externalInflow: 600,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 700,
|
||||||
|
allocations: new Map([['F', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'D',
|
||||||
|
name: 'David',
|
||||||
|
externalInflow: 1000,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 900,
|
||||||
|
allocations: new Map([
|
||||||
|
['G', 40],
|
||||||
|
['H', 60],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'E',
|
||||||
|
name: 'Eve',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 400,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['F', 50],
|
||||||
|
['G', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'F',
|
||||||
|
name: 'Frank',
|
||||||
|
externalInflow: 500,
|
||||||
|
minThreshold: 300,
|
||||||
|
maxThreshold: 600,
|
||||||
|
allocations: new Map([['H', 100]]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'G',
|
||||||
|
name: 'Grace',
|
||||||
|
externalInflow: 300,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 1000,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 30],
|
||||||
|
['H', 70],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'H',
|
||||||
|
name: 'Henry',
|
||||||
|
externalInflow: 200,
|
||||||
|
minThreshold: 600,
|
||||||
|
maxThreshold: 1200,
|
||||||
|
allocations: new Map([
|
||||||
|
['A', 20],
|
||||||
|
['E', 80],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['A', { x: 150, y: 150 }],
|
||||||
|
['B', { x: 380, y: 100 }],
|
||||||
|
['C', { x: 380, y: 200 }],
|
||||||
|
['D', { x: 610, y: 150 }],
|
||||||
|
['E', { x: 610, y: 350 }],
|
||||||
|
['F', { x: 380, y: 400 }],
|
||||||
|
['G', { x: 150, y: 400 }],
|
||||||
|
['H', { x: 150, y: 550 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario 5: Worker Cooperative
|
||||||
|
* Models a worker coop with shared risk pool
|
||||||
|
*/
|
||||||
|
export const workerCoopV2: ScenarioV2 = {
|
||||||
|
id: 'worker-coop-v2',
|
||||||
|
name: 'Worker Cooperative',
|
||||||
|
description:
|
||||||
|
'Five workers in a cooperative. Each worker\'s overflow goes partly to a shared ' +
|
||||||
|
'risk pool and partly to supporting other workers, creating solidarity and resilience. ' +
|
||||||
|
'The pool redistributes evenly to all workers.',
|
||||||
|
suggestedTotalInflow: 3000,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'Pool',
|
||||||
|
name: 'Risk Pool',
|
||||||
|
externalInflow: 1000,
|
||||||
|
minThreshold: 1500,
|
||||||
|
maxThreshold: 3000,
|
||||||
|
allocations: new Map([
|
||||||
|
['W1', 20],
|
||||||
|
['W2', 20],
|
||||||
|
['W3', 20],
|
||||||
|
['W4', 20],
|
||||||
|
['W5', 20],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W1',
|
||||||
|
name: 'Worker 1',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W2', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W2',
|
||||||
|
name: 'Worker 2',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W3', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W3',
|
||||||
|
name: 'Worker 3',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W4', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W4',
|
||||||
|
name: 'Worker 4',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W5', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'W5',
|
||||||
|
name: 'Worker 5',
|
||||||
|
externalInflow: 400,
|
||||||
|
minThreshold: 500,
|
||||||
|
maxThreshold: 800,
|
||||||
|
allocations: new Map([
|
||||||
|
['Pool', 50],
|
||||||
|
['W1', 50],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
layout: new Map([
|
||||||
|
['Pool', { x: 370, y: 300 }],
|
||||||
|
['W1', { x: 370, y: 120 }],
|
||||||
|
['W2', { x: 570, y: 210 }],
|
||||||
|
['W3', { x: 570, y: 390 }],
|
||||||
|
['W4', { x: 370, y: 480 }],
|
||||||
|
['W5', { x: 170, y: 390 }],
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All available scenarios
|
||||||
|
*/
|
||||||
|
export const ALL_SCENARIOS_V2: ScenarioV2[] = [
|
||||||
|
linearChainV2,
|
||||||
|
mutualAidCircleV2,
|
||||||
|
hubAndSpokeV2,
|
||||||
|
complexNetworkV2,
|
||||||
|
workerCoopV2,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get scenario by ID
|
||||||
|
*/
|
||||||
|
export function getScenarioV2(id: string): ScenarioV2 | undefined {
|
||||||
|
return ALL_SCENARIOS_V2.find(s => s.id === id)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding V2 - Continuous Flow Dynamics
|
||||||
|
*
|
||||||
|
* Core types for the flow-oriented funding mechanism
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow Node - A participant in the flow network
|
||||||
|
*
|
||||||
|
* Each node has:
|
||||||
|
* - External inflow ($/month) - what funders contribute
|
||||||
|
* - Min threshold ($/month) - needs level
|
||||||
|
* - Max threshold ($/month) - capacity level
|
||||||
|
* - Allocations - where overflow flows to
|
||||||
|
*/
|
||||||
|
export interface FlowNode {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
|
||||||
|
// Flow rates ($/month for UI, converted to $/second for simulation)
|
||||||
|
externalInflow: number // From funders/sliders
|
||||||
|
minThreshold: number // Needs level
|
||||||
|
maxThreshold: number // Capacity level
|
||||||
|
|
||||||
|
// Where overflow flows to (percentages sum to ≤100)
|
||||||
|
allocations: Map<string, number>
|
||||||
|
|
||||||
|
// Computed during steady-state calculation
|
||||||
|
totalInflow?: number // External + incoming from other nodes
|
||||||
|
totalOutflow?: number // Sent to other nodes
|
||||||
|
balance?: number // Accumulated balance (for visualization only)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progressive Outflow Zones
|
||||||
|
*
|
||||||
|
* Deficit Zone (totalInflow < min): Keep everything, outflow = 0
|
||||||
|
* Building Zone (min ≤ totalInflow ≤ max): Progressive sharing
|
||||||
|
* Capacity Zone (totalInflow > max): Redirect all excess
|
||||||
|
*/
|
||||||
|
export type FlowZone = 'deficit' | 'building' | 'capacity'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow between two nodes
|
||||||
|
*/
|
||||||
|
export interface FlowEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
flowRate: number // $/month
|
||||||
|
percentage: number // Allocation percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network Overflow Node
|
||||||
|
*
|
||||||
|
* Pure sink that absorbs unallocatable overflow
|
||||||
|
* Created when total external inflow > total network capacity
|
||||||
|
*/
|
||||||
|
export interface OverflowNode {
|
||||||
|
id: 'overflow'
|
||||||
|
totalInflow: number // $/month
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete network state
|
||||||
|
*/
|
||||||
|
export interface FlowNetwork {
|
||||||
|
nodes: Map<string, FlowNode>
|
||||||
|
edges: FlowEdge[]
|
||||||
|
overflowNode: OverflowNode | null
|
||||||
|
|
||||||
|
// Network-level metrics
|
||||||
|
totalExternalInflow: number // Sum of all external inflows
|
||||||
|
totalNetworkCapacity: number // Sum of all max thresholds
|
||||||
|
totalNetworkNeeds: number // Sum of all min thresholds
|
||||||
|
|
||||||
|
// Convergence info
|
||||||
|
converged: boolean
|
||||||
|
iterations: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulation state (per-frame)
|
||||||
|
*/
|
||||||
|
export interface SimulationState {
|
||||||
|
timestamp: number // Simulation time in seconds
|
||||||
|
network: FlowNetwork
|
||||||
|
|
||||||
|
// Per-node state
|
||||||
|
nodeStates: Map<string, {
|
||||||
|
zone: FlowZone
|
||||||
|
inflows: Map<string, number> // From specific sources
|
||||||
|
outflows: Map<string, number> // To specific targets
|
||||||
|
balance: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenario preset
|
||||||
|
*/
|
||||||
|
export interface ScenarioV2 {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
nodes: FlowNode[]
|
||||||
|
layout: Map<string, { x: number; y: number }>
|
||||||
|
suggestedTotalInflow: number // $/month
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
# Threshold-Based Flow Funding (TBFF) Module
|
||||||
|
|
||||||
|
**Status**: Milestone 3 Complete ✅
|
||||||
|
**Route**: `/tbff`
|
||||||
|
**Last Updated**: 2025-11-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This module implements the Threshold-Based Flow Funding mechanism described in `threshold-based-flow-funding.md`. It's built as a **self-contained, modular system** that can evolve independently without affecting other parts of the application.
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/tbff/
|
||||||
|
├── types.ts # TypeScript interfaces and types
|
||||||
|
├── utils.ts # Utility functions (status calculations, formatting)
|
||||||
|
├── sample-networks.ts # Pre-configured demo networks
|
||||||
|
├── rendering.ts # Canvas rendering functions
|
||||||
|
├── algorithms.ts # Flow funding algorithms (future)
|
||||||
|
└── README.md # This file
|
||||||
|
|
||||||
|
app/tbff/
|
||||||
|
└── page.tsx # Main page component
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### 1. Account (Participant)
|
||||||
|
|
||||||
|
Each account has:
|
||||||
|
- **Balance**: Current funds held
|
||||||
|
- **Min Threshold**: Minimum viable funding (survival level)
|
||||||
|
- **Max Threshold**: Overflow point (abundance level)
|
||||||
|
- **Status**: Derived state (deficit, minimum, healthy, overflow)
|
||||||
|
|
||||||
|
**Visual Representation**: Rectangle with fill height showing balance vs thresholds.
|
||||||
|
|
||||||
|
**Color Coding**:
|
||||||
|
- 🔴 Red (Deficit): balance < minThreshold
|
||||||
|
- 🟡 Yellow (Minimum): balance ≈ minThreshold
|
||||||
|
- 🔵 Blue (Healthy): minThreshold < balance < maxThreshold
|
||||||
|
- 🟢 Green (Overflow): balance ≥ maxThreshold
|
||||||
|
|
||||||
|
### 2. Allocation (Connection)
|
||||||
|
|
||||||
|
Represents where overflow flows when an account exceeds its maximum threshold.
|
||||||
|
|
||||||
|
**Properties**:
|
||||||
|
- `sourceAccountId`: Account that overflows
|
||||||
|
- `targetAccountId`: Account that receives overflow
|
||||||
|
- `percentage`: Portion of overflow to send (0.0 to 1.0)
|
||||||
|
|
||||||
|
**Visual Representation**: Arrow with thickness based on percentage.
|
||||||
|
|
||||||
|
### 3. Network
|
||||||
|
|
||||||
|
Collection of accounts and their allocations, forming a resource flow network.
|
||||||
|
|
||||||
|
**Computed Properties**:
|
||||||
|
- Total Funds: Sum of all balances
|
||||||
|
- Total Shortfall: Sum of all deficits
|
||||||
|
- Total Capacity: Sum of all remaining capacity
|
||||||
|
- Total Overflow: Sum of all overflows
|
||||||
|
|
||||||
|
## Current Implementation (Milestone 1-3)
|
||||||
|
|
||||||
|
### ✅ What's Working
|
||||||
|
|
||||||
|
1. **Static Visualization**
|
||||||
|
- Accounts rendered as colored rectangles
|
||||||
|
- Fill height shows balance vs max threshold
|
||||||
|
- Threshold lines (dashed) show min/max
|
||||||
|
- Status badges show current state
|
||||||
|
- Center dots show connection points
|
||||||
|
|
||||||
|
2. **Allocations**
|
||||||
|
- Arrows between accounts
|
||||||
|
- Thickness based on allocation percentage
|
||||||
|
- Color indicates if source has overflow
|
||||||
|
- Percentage labels at midpoint
|
||||||
|
|
||||||
|
3. **Interactive Selection**
|
||||||
|
- Click accounts to select
|
||||||
|
- Click arrows to select allocations
|
||||||
|
- Sidebar shows detailed info
|
||||||
|
- Account list for quick navigation
|
||||||
|
- Keyboard shortcuts (Delete, Escape)
|
||||||
|
|
||||||
|
4. **Interactive Allocation Creation** ✨ New in M2
|
||||||
|
- Two-tool system (Select, Create Arrow)
|
||||||
|
- Click source, then target to create allocation
|
||||||
|
- Default 50% percentage
|
||||||
|
- Auto-normalization with existing allocations
|
||||||
|
- Visual feedback during creation
|
||||||
|
|
||||||
|
5. **Allocation Editing** ✨ New in M2
|
||||||
|
- Select arrow to edit
|
||||||
|
- Percentage slider (0-100%)
|
||||||
|
- Real-time updates
|
||||||
|
- Auto-normalization
|
||||||
|
- Delete button
|
||||||
|
- Delete key shortcut
|
||||||
|
|
||||||
|
6. **Sample Networks**
|
||||||
|
- **States Demo**: Shows all 4 account states
|
||||||
|
- **Simple Linear**: A → B → C flow
|
||||||
|
- **Mutual Aid Circle**: A ↔ B ↔ C circular support
|
||||||
|
- **Commons Pool**: Everyone → Pool → Everyone
|
||||||
|
|
||||||
|
7. **Initial Distribution Algorithm** ✨ New in M3
|
||||||
|
- Add external funding input field
|
||||||
|
- "Distribute Funding" button
|
||||||
|
- Algorithm fills minimums first, then distributes by capacity
|
||||||
|
- Distribution summary shows changes
|
||||||
|
- Console logging for debugging
|
||||||
|
- Real-time balance updates
|
||||||
|
|
||||||
|
8. **Network Stats**
|
||||||
|
- Real-time totals displayed in corner
|
||||||
|
- Sidebar shows aggregated metrics
|
||||||
|
|
||||||
|
### 📋 What's Not Yet Implemented
|
||||||
|
|
||||||
|
- ❌ Overflow redistribution algorithm
|
||||||
|
- ❌ Animated flow particles
|
||||||
|
- ❌ Adding/editing accounts
|
||||||
|
- ❌ Editing account balances/thresholds
|
||||||
|
- ❌ Multi-round simulation with overflow
|
||||||
|
- ❌ Persistence (save/load)
|
||||||
|
|
||||||
|
## Sample Networks
|
||||||
|
|
||||||
|
### 1. States Demo (Default)
|
||||||
|
|
||||||
|
Four accounts showing all possible states:
|
||||||
|
- Deficit (balance: 30, min: 100, max: 200)
|
||||||
|
- Minimum (balance: 100, min: 100, max: 200)
|
||||||
|
- Healthy (balance: 150, min: 100, max: 200)
|
||||||
|
- Overflow (balance: 250, min: 100, max: 200)
|
||||||
|
|
||||||
|
**Purpose**: Understand visual language and status colors.
|
||||||
|
|
||||||
|
### 2. Simple Linear Flow
|
||||||
|
|
||||||
|
Three accounts in a chain: Alice → Bob → Carol
|
||||||
|
|
||||||
|
**Purpose**: Demonstrates basic flow through a linear network.
|
||||||
|
|
||||||
|
### 3. Mutual Aid Circle
|
||||||
|
|
||||||
|
Three accounts in circular support: Alice ↔ Bob ↔ Carol ↔ Alice
|
||||||
|
|
||||||
|
**Purpose**: Shows how resources can circulate through mutual aid relationships.
|
||||||
|
|
||||||
|
### 4. Commons Pool
|
||||||
|
|
||||||
|
Four accounts where everyone contributes to a central pool, which redistributes equally.
|
||||||
|
|
||||||
|
**Purpose**: Demonstrates hub-and-spoke pattern with commons-based allocation.
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Types (`types.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FlowFundingAccount {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
balance: number
|
||||||
|
minThreshold: number
|
||||||
|
maxThreshold: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
status: AccountStatus
|
||||||
|
shortfall: number
|
||||||
|
capacity: number
|
||||||
|
overflow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Allocation {
|
||||||
|
id: string
|
||||||
|
sourceAccountId: string
|
||||||
|
targetAccountId: string
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowFundingNetwork {
|
||||||
|
name: string
|
||||||
|
accounts: FlowFundingAccount[]
|
||||||
|
allocations: Allocation[]
|
||||||
|
totalFunds: number
|
||||||
|
totalShortfall: number
|
||||||
|
totalCapacity: number
|
||||||
|
totalOverflow: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utils (`utils.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Status calculation
|
||||||
|
getAccountStatus(account: FlowFundingAccount): AccountStatus
|
||||||
|
updateAccountComputedProperties(account: FlowFundingAccount): FlowFundingAccount
|
||||||
|
|
||||||
|
// Network calculations
|
||||||
|
calculateNetworkTotals(network: FlowFundingNetwork): FlowFundingNetwork
|
||||||
|
|
||||||
|
// Allocation helpers
|
||||||
|
normalizeAllocations(allocations: Allocation[]): Allocation[]
|
||||||
|
|
||||||
|
// Visual helpers
|
||||||
|
getAccountCenter(account: FlowFundingAccount): { x: number; y: number }
|
||||||
|
getStatusColor(status: AccountStatus, alpha?: number): string
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rendering (`rendering.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Render individual elements
|
||||||
|
renderAccount(ctx: CanvasRenderingContext2D, account: FlowFundingAccount, isSelected?: boolean): void
|
||||||
|
renderAllocation(ctx: CanvasRenderingContext2D, allocation: Allocation, source: FlowFundingAccount, target: FlowFundingAccount, isSelected?: boolean): void
|
||||||
|
|
||||||
|
// Render entire network
|
||||||
|
renderNetwork(ctx: CanvasRenderingContext2D, network: FlowFundingNetwork, width: number, height: number, selectedAccountId?: string | null): void
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps (Milestone 2+)
|
||||||
|
|
||||||
|
### ✅ Milestone 2: Add Allocations (Interactive) - COMPLETE
|
||||||
|
**Goal**: Draw arrows between accounts, edit percentages
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [x] Arrow drawing tool (click source, click target)
|
||||||
|
- [x] Allocation percentage editor in sidebar
|
||||||
|
- [x] Delete allocations
|
||||||
|
- [x] Normalize allocations automatically
|
||||||
|
|
||||||
|
### ✅ Milestone 3: Initial Distribution - COMPLETE
|
||||||
|
**Goal**: Add external funding and watch it distribute
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [x] Implement `initialDistribution()` algorithm
|
||||||
|
- [x] Add "Add Funding" input + button
|
||||||
|
- [x] Distribution summary display
|
||||||
|
- [x] Console logging for debugging
|
||||||
|
- [ ] Animate balance changes (number tweening) - Future enhancement
|
||||||
|
|
||||||
|
### Milestone 4: Overflow Redistribution
|
||||||
|
**Goal**: Trigger overflow and watch funds flow
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Implement `redistributeOverflow()` algorithm
|
||||||
|
- [ ] Create `FlowParticle` animation system
|
||||||
|
- [ ] Animate particles along arrows
|
||||||
|
- [ ] Show iteration count and convergence
|
||||||
|
- [ ] "Run Redistribution" button
|
||||||
|
|
||||||
|
### Milestone 5: Interactive Creation
|
||||||
|
**Goal**: Build custom networks from scratch
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] "Create Account" tool with threshold inputs
|
||||||
|
- [ ] Drag accounts to reposition
|
||||||
|
- [ ] Edit account thresholds
|
||||||
|
- [ ] Edit account balances
|
||||||
|
- [ ] Save/load network (localStorage)
|
||||||
|
|
||||||
|
### Milestone 6: Scenarios & Presets
|
||||||
|
**Goal**: Curated examples with explanations
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] More complex preset networks
|
||||||
|
- [ ] Guided tour / tooltips
|
||||||
|
- [ ] Scenario descriptions
|
||||||
|
- [ ] Expected outcomes documentation
|
||||||
|
|
||||||
|
### Milestone 7: Polish
|
||||||
|
**Goal**: Production-ready demo
|
||||||
|
|
||||||
|
**Tasks**:
|
||||||
|
- [ ] Keyboard shortcuts (Delete, Esc, etc.)
|
||||||
|
- [ ] Undo/redo for edits
|
||||||
|
- [ ] Mobile responsive sidebar
|
||||||
|
- [ ] Performance optimization
|
||||||
|
- [ ] Error handling
|
||||||
|
- [ ] Demo video recording
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### With Existing Canvas (`/italism`)
|
||||||
|
|
||||||
|
This module is **completely separate** from the existing `/italism` canvas. No shared code, no dependencies.
|
||||||
|
|
||||||
|
**Future**: Could potentially merge propagator concepts, but for now they remain independent.
|
||||||
|
|
||||||
|
### With Academic Paper
|
||||||
|
|
||||||
|
This implementation directly models the concepts from `threshold-based-flow-funding.md`:
|
||||||
|
|
||||||
|
- **Section 2.1**: Mathematical Model → `types.ts` interfaces
|
||||||
|
- **Section 2.2**: Distribution Algorithm → `algorithms.ts` (future)
|
||||||
|
- **Section 3**: Theoretical Properties → Will validate through tests
|
||||||
|
|
||||||
|
### With Post-Appitalism Vision
|
||||||
|
|
||||||
|
This embodies Post-Appitalism by:
|
||||||
|
- Making abstract economics **tangible** (visual, interactive)
|
||||||
|
- Demonstrating **resource circulation** vs extraction
|
||||||
|
- Showing **collective intelligence** (allocation networks)
|
||||||
|
- Creating **malleable** systems (users can experiment)
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
1. **Separate Module**: Keeps TBFF isolated, prevents breaking existing features
|
||||||
|
2. **Canvas-based**: Performance for many accounts, smooth animations
|
||||||
|
3. **Computed Properties**: Derived from balance/thresholds, not stored separately
|
||||||
|
4. **Sample Data**: Hardcoded networks for quick demos, easier testing
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
1. **No persistence**: Refresh loses changes (Milestone 5)
|
||||||
|
2. **Static only**: No algorithm execution yet (Milestone 3-4)
|
||||||
|
3. **No validation**: Can't detect invalid networks yet
|
||||||
|
4. **No tests**: Should add unit tests for algorithms
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Canvas redraws entire scene on change (acceptable for <50 accounts)
|
||||||
|
- Could optimize with dirty rectangles if needed
|
||||||
|
- Animations will use `requestAnimationFrame`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
|
||||||
|
**Milestone 1:**
|
||||||
|
- [x] Load default network (States Demo)
|
||||||
|
- [x] Switch between networks via dropdown
|
||||||
|
- [x] Click accounts to select
|
||||||
|
- [x] View account details in sidebar
|
||||||
|
- [x] See color coding for different states
|
||||||
|
- [x] See threshold lines in accounts
|
||||||
|
- [x] See allocation arrows with percentages
|
||||||
|
- [x] See network stats update
|
||||||
|
|
||||||
|
**Milestone 2:**
|
||||||
|
- [x] Select "Create Arrow" tool
|
||||||
|
- [x] Click source account, then target account
|
||||||
|
- [x] New allocation appears on canvas
|
||||||
|
- [x] Click arrow to select it
|
||||||
|
- [x] Selected arrow highlights in cyan
|
||||||
|
- [x] Allocation editor appears in sidebar
|
||||||
|
- [x] Drag percentage slider
|
||||||
|
- [x] See percentage update in real-time
|
||||||
|
- [x] Create second allocation from same source
|
||||||
|
- [x] See both allocations normalize
|
||||||
|
- [x] Click "Delete Allocation" button
|
||||||
|
- [x] Press Delete key to remove allocation
|
||||||
|
- [x] Press Escape to deselect
|
||||||
|
- [x] See outgoing allocations in account details
|
||||||
|
|
||||||
|
**Milestone 3:**
|
||||||
|
- [x] See "Add Funding" section in sidebar
|
||||||
|
- [x] Enter funding amount (default: 1000)
|
||||||
|
- [x] Click "Distribute Funding" button
|
||||||
|
- [x] See balances update immediately
|
||||||
|
- [x] See distribution summary appear
|
||||||
|
- [x] See list of changed accounts with deltas
|
||||||
|
- [x] Check console for detailed logs
|
||||||
|
- [x] Try insufficient funding (distributes proportionally)
|
||||||
|
- [x] Try sufficient funding (fills minimums, then by capacity)
|
||||||
|
- [x] See network totals update correctly
|
||||||
|
|
||||||
|
**Future:**
|
||||||
|
- [ ] Watch overflow redistribution (Milestone 4)
|
||||||
|
- [ ] See animated flow particles (Milestone 4)
|
||||||
|
|
||||||
|
### Future: Automated Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example tests for Milestone 3+
|
||||||
|
describe('initialDistribution', () => {
|
||||||
|
it('should fill minimums first when funds insufficient', () => {})
|
||||||
|
it('should distribute by capacity when minimums met', () => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redistributeOverflow', () => {
|
||||||
|
it('should converge within max iterations', () => {})
|
||||||
|
it('should conserve total funds', () => {})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- **Academic Paper**: `../../../threshold-based-flow-funding.md`
|
||||||
|
- **Design Session**: `../../.claude/journal/FLOW_FUNDING_DESIGN_SESSION.md`
|
||||||
|
- **Project Vision**: `../../.claude/journal/POST_APPITALISM_VISION.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with**: TypeScript, React, Next.js, Canvas API
|
||||||
|
**Module Owner**: TBFF Team
|
||||||
|
**Questions?** See design session document for detailed architecture.
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
/**
|
||||||
|
* Flow Funding algorithms
|
||||||
|
* Implements the mathematical model from threshold-based-flow-funding.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FlowFundingNetwork, FlowFundingAccount } from './types'
|
||||||
|
import { updateAccountComputedProperties, calculateNetworkTotals } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial distribution of external funding to accounts
|
||||||
|
*
|
||||||
|
* Algorithm:
|
||||||
|
* 1. Calculate total shortfall (funds needed to reach minimums)
|
||||||
|
* 2. If funding < shortfall: distribute proportionally to shortfalls
|
||||||
|
* 3. If funding >= shortfall: fill all minimums first, then distribute remaining by capacity
|
||||||
|
*
|
||||||
|
* @param network - Current network state
|
||||||
|
* @param externalFunding - Amount of new funding to distribute
|
||||||
|
* @returns Updated network with new balances
|
||||||
|
*/
|
||||||
|
export function initialDistribution(
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
externalFunding: number
|
||||||
|
): FlowFundingNetwork {
|
||||||
|
if (externalFunding <= 0) {
|
||||||
|
console.warn('⚠️ No funding to distribute')
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n💰 Initial Distribution: ${externalFunding} funding`)
|
||||||
|
console.log('━'.repeat(50))
|
||||||
|
|
||||||
|
// Calculate total shortfall (funds needed to reach minimums)
|
||||||
|
const totalShortfall = network.accounts.reduce(
|
||||||
|
(sum, acc) => sum + Math.max(0, acc.minThreshold - acc.balance),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Total shortfall: ${totalShortfall.toFixed(2)}`)
|
||||||
|
|
||||||
|
if (externalFunding < totalShortfall) {
|
||||||
|
// Not enough to cover all minimums - distribute proportionally
|
||||||
|
console.log('⚠️ Insufficient funding to cover all minimums')
|
||||||
|
console.log('Distributing proportionally by shortfall...\n')
|
||||||
|
|
||||||
|
return distributeProportionallyByShortfall(network, externalFunding, totalShortfall)
|
||||||
|
} else {
|
||||||
|
// Enough funding - fill minimums first, then distribute by capacity
|
||||||
|
console.log('✓ Sufficient funding to cover all minimums')
|
||||||
|
console.log('Step 1: Filling all minimums...')
|
||||||
|
|
||||||
|
const afterMinimums = fillAllMinimums(network)
|
||||||
|
const remainingFunds = externalFunding - totalShortfall
|
||||||
|
|
||||||
|
console.log(`Remaining funds: ${remainingFunds.toFixed(2)}`)
|
||||||
|
console.log('Step 2: Distributing by capacity...\n')
|
||||||
|
|
||||||
|
return distributeByCapacity(afterMinimums, remainingFunds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute funding proportionally to shortfalls
|
||||||
|
* Used when funding is insufficient to cover all minimums
|
||||||
|
*/
|
||||||
|
function distributeProportionallyByShortfall(
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
funding: number,
|
||||||
|
totalShortfall: number
|
||||||
|
): FlowFundingNetwork {
|
||||||
|
const updatedAccounts = network.accounts.map((acc) => {
|
||||||
|
const shortfall = Math.max(0, acc.minThreshold - acc.balance)
|
||||||
|
if (shortfall === 0) return acc
|
||||||
|
|
||||||
|
const share = (shortfall / totalShortfall) * funding
|
||||||
|
const newBalance = acc.balance + share
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${share.toFixed(0)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return updateAccountComputedProperties({
|
||||||
|
...acc,
|
||||||
|
balance: newBalance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
accounts: updatedAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill all accounts to their minimum thresholds
|
||||||
|
*/
|
||||||
|
function fillAllMinimums(network: FlowFundingNetwork): FlowFundingNetwork {
|
||||||
|
const updatedAccounts = network.accounts.map((acc) => {
|
||||||
|
const shortfall = Math.max(0, acc.minThreshold - acc.balance)
|
||||||
|
if (shortfall === 0) {
|
||||||
|
console.log(` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} (already at minimum)`)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
const newBalance = acc.minThreshold
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${shortfall.toFixed(0)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return updateAccountComputedProperties({
|
||||||
|
...acc,
|
||||||
|
balance: newBalance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
accounts: updatedAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute funding proportionally to account capacities
|
||||||
|
* Capacity = max(0, maxThreshold - balance)
|
||||||
|
*/
|
||||||
|
function distributeByCapacity(
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
funding: number
|
||||||
|
): FlowFundingNetwork {
|
||||||
|
if (funding <= 0) {
|
||||||
|
console.log(' No remaining funds to distribute')
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total capacity
|
||||||
|
const totalCapacity = network.accounts.reduce(
|
||||||
|
(sum, acc) => sum + Math.max(0, acc.maxThreshold - acc.balance),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (totalCapacity === 0) {
|
||||||
|
// All accounts at max - distribute evenly (will create overflow)
|
||||||
|
console.log(' All accounts at max capacity - distributing evenly (will overflow)')
|
||||||
|
return distributeEvenly(network, funding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute proportionally to capacity
|
||||||
|
const updatedAccounts = network.accounts.map((acc) => {
|
||||||
|
const capacity = Math.max(0, acc.maxThreshold - acc.balance)
|
||||||
|
if (capacity === 0) {
|
||||||
|
console.log(` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} (at max capacity)`)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = (capacity / totalCapacity) * funding
|
||||||
|
const newBalance = acc.balance + share
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${share.toFixed(0)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return updateAccountComputedProperties({
|
||||||
|
...acc,
|
||||||
|
balance: newBalance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
accounts: updatedAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distribute funding evenly across all accounts
|
||||||
|
* Used when all accounts are at max capacity
|
||||||
|
*/
|
||||||
|
function distributeEvenly(
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
funding: number
|
||||||
|
): FlowFundingNetwork {
|
||||||
|
const perAccount = funding / network.accounts.length
|
||||||
|
|
||||||
|
const updatedAccounts = network.accounts.map((acc) => {
|
||||||
|
const newBalance = acc.balance + perAccount
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
` ${acc.name.padEnd(15)} ${acc.balance.toFixed(0)} → ${newBalance.toFixed(0)} (+${perAccount.toFixed(0)})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return updateAccountComputedProperties({
|
||||||
|
...acc,
|
||||||
|
balance: newBalance,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return calculateNetworkTotals({
|
||||||
|
...network,
|
||||||
|
accounts: updatedAccounts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate distribution summary (for UI display)
|
||||||
|
*/
|
||||||
|
export function getDistributionSummary(
|
||||||
|
beforeNetwork: FlowFundingNetwork,
|
||||||
|
afterNetwork: FlowFundingNetwork
|
||||||
|
): {
|
||||||
|
totalDistributed: number
|
||||||
|
accountsChanged: number
|
||||||
|
changes: Array<{ accountId: string; name: string; before: number; after: number; delta: number }>
|
||||||
|
} {
|
||||||
|
const changes = afterNetwork.accounts.map((after) => {
|
||||||
|
const before = beforeNetwork.accounts.find((a) => a.id === after.id)!
|
||||||
|
const delta = after.balance - before.balance
|
||||||
|
|
||||||
|
return {
|
||||||
|
accountId: after.id,
|
||||||
|
name: after.name,
|
||||||
|
before: before.balance,
|
||||||
|
after: after.balance,
|
||||||
|
delta,
|
||||||
|
}
|
||||||
|
}).filter(c => c.delta !== 0)
|
||||||
|
|
||||||
|
const totalDistributed = changes.reduce((sum, c) => sum + c.delta, 0)
|
||||||
|
const accountsChanged = changes.length
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDistributed,
|
||||||
|
accountsChanged,
|
||||||
|
changes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
/**
|
||||||
|
* Canvas rendering functions for Flow Funding visualization
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FlowFundingAccount, FlowFundingNetwork, Allocation } from './types'
|
||||||
|
import { getStatusColor, getAccountCenter, formatCurrency, formatPercentage } from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw threshold line inside account rectangle
|
||||||
|
*/
|
||||||
|
function drawThresholdLine(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
account: FlowFundingAccount,
|
||||||
|
threshold: number,
|
||||||
|
color: string,
|
||||||
|
label: string
|
||||||
|
) {
|
||||||
|
if (threshold <= 0) return
|
||||||
|
|
||||||
|
const thresholdRatio = threshold / account.maxThreshold
|
||||||
|
const lineY = account.y + account.height - thresholdRatio * account.height
|
||||||
|
|
||||||
|
// Draw dashed line
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.setLineDash([5, 5])
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(account.x, lineY)
|
||||||
|
ctx.lineTo(account.x + account.width, lineY)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.setLineDash([])
|
||||||
|
|
||||||
|
// Draw label
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = 'bold 10px sans-serif'
|
||||||
|
ctx.fillText(label, account.x + 5, lineY - 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a Flow Funding account as a colored rectangle
|
||||||
|
*/
|
||||||
|
export function renderAccount(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
account: FlowFundingAccount,
|
||||||
|
isSelected: boolean = false
|
||||||
|
) {
|
||||||
|
// Draw border (thicker if selected)
|
||||||
|
ctx.strokeStyle = isSelected ? '#22d3ee' : getStatusColor(account.status)
|
||||||
|
ctx.lineWidth = isSelected ? 4 : 3
|
||||||
|
ctx.strokeRect(account.x, account.y, account.width, account.height)
|
||||||
|
|
||||||
|
// Calculate fill height based on balance
|
||||||
|
const fillRatio = Math.min(account.balance / account.maxThreshold, 1)
|
||||||
|
const fillHeight = fillRatio * account.height
|
||||||
|
const fillY = account.y + account.height - fillHeight
|
||||||
|
|
||||||
|
// Draw fill with gradient
|
||||||
|
const gradient = ctx.createLinearGradient(
|
||||||
|
account.x,
|
||||||
|
account.y,
|
||||||
|
account.x,
|
||||||
|
account.y + account.height
|
||||||
|
)
|
||||||
|
gradient.addColorStop(0, getStatusColor(account.status, 0.2))
|
||||||
|
gradient.addColorStop(1, getStatusColor(account.status, 0.6))
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.fillRect(account.x, fillY, account.width, fillHeight)
|
||||||
|
|
||||||
|
// Draw threshold lines
|
||||||
|
if (account.minThreshold > 0) {
|
||||||
|
drawThresholdLine(ctx, account, account.minThreshold, '#ef4444', 'Min')
|
||||||
|
}
|
||||||
|
drawThresholdLine(ctx, account, account.maxThreshold, '#10b981', 'Max')
|
||||||
|
|
||||||
|
// Draw text labels
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = 'bold 16px sans-serif'
|
||||||
|
ctx.fillText(account.name, account.x + 10, account.y + 25)
|
||||||
|
|
||||||
|
ctx.font = '13px monospace'
|
||||||
|
ctx.fillStyle = '#e2e8f0'
|
||||||
|
ctx.fillText(`Balance: ${formatCurrency(account.balance)}`, account.x + 10, account.y + 50)
|
||||||
|
|
||||||
|
ctx.font = '11px sans-serif'
|
||||||
|
ctx.fillStyle = '#cbd5e1'
|
||||||
|
ctx.fillText(`Min: ${formatCurrency(account.minThreshold)}`, account.x + 10, account.y + 70)
|
||||||
|
ctx.fillText(`Max: ${formatCurrency(account.maxThreshold)}`, account.x + 10, account.y + 85)
|
||||||
|
|
||||||
|
// Show status badge
|
||||||
|
const statusColors = {
|
||||||
|
deficit: '#ef4444',
|
||||||
|
minimum: '#eab308',
|
||||||
|
healthy: '#6366f1',
|
||||||
|
overflow: '#10b981',
|
||||||
|
}
|
||||||
|
const statusLabels = {
|
||||||
|
deficit: 'DEFICIT',
|
||||||
|
minimum: 'AT MIN',
|
||||||
|
healthy: 'HEALTHY',
|
||||||
|
overflow: 'OVERFLOW',
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = statusColors[account.status]
|
||||||
|
ctx.font = 'bold 10px sans-serif'
|
||||||
|
const statusText = statusLabels[account.status]
|
||||||
|
const statusWidth = ctx.measureText(statusText).width
|
||||||
|
ctx.fillRect(account.x + account.width - statusWidth - 15, account.y + 8, statusWidth + 10, 18)
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fillText(statusText, account.x + account.width - statusWidth - 10, account.y + 20)
|
||||||
|
|
||||||
|
// Show overflow/shortfall amount if significant
|
||||||
|
if (account.overflow > 0) {
|
||||||
|
ctx.fillStyle = '#10b981'
|
||||||
|
ctx.font = 'bold 12px sans-serif'
|
||||||
|
ctx.fillText(
|
||||||
|
`+${formatCurrency(account.overflow)} overflow`,
|
||||||
|
account.x + 10,
|
||||||
|
account.y + account.height - 10
|
||||||
|
)
|
||||||
|
} else if (account.shortfall > 0) {
|
||||||
|
ctx.fillStyle = '#ef4444'
|
||||||
|
ctx.font = 'bold 12px sans-serif'
|
||||||
|
ctx.fillText(
|
||||||
|
`-${formatCurrency(account.shortfall)} needed`,
|
||||||
|
account.x + 10,
|
||||||
|
account.y + account.height - 10
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw center dot (connection point)
|
||||||
|
const center = getAccountCenter(account)
|
||||||
|
ctx.fillStyle = '#22d3ee'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(center.x, center.y, 4, 0, 2 * Math.PI)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw arrowhead at end of line
|
||||||
|
*/
|
||||||
|
function drawArrowhead(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
color: string,
|
||||||
|
size: number = 15
|
||||||
|
) {
|
||||||
|
const angle = Math.atan2(y2 - y1, x2 - x1)
|
||||||
|
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(x2, y2)
|
||||||
|
ctx.lineTo(
|
||||||
|
x2 - size * Math.cos(angle - Math.PI / 6),
|
||||||
|
y2 - size * Math.sin(angle - Math.PI / 6)
|
||||||
|
)
|
||||||
|
ctx.lineTo(
|
||||||
|
x2 - size * Math.cos(angle + Math.PI / 6),
|
||||||
|
y2 - size * Math.sin(angle + Math.PI / 6)
|
||||||
|
)
|
||||||
|
ctx.closePath()
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an allocation arrow between accounts
|
||||||
|
*/
|
||||||
|
export function renderAllocation(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
allocation: Allocation,
|
||||||
|
sourceAccount: FlowFundingAccount,
|
||||||
|
targetAccount: FlowFundingAccount,
|
||||||
|
isSelected: boolean = false
|
||||||
|
) {
|
||||||
|
const start = getAccountCenter(sourceAccount)
|
||||||
|
const end = getAccountCenter(targetAccount)
|
||||||
|
|
||||||
|
// Line thickness based on percentage
|
||||||
|
const baseWidth = 2
|
||||||
|
const maxWidth = 10
|
||||||
|
const width = baseWidth + allocation.percentage * (maxWidth - baseWidth)
|
||||||
|
|
||||||
|
// Color based on whether source has overflow
|
||||||
|
const hasOverflow = sourceAccount.balance > sourceAccount.maxThreshold
|
||||||
|
const color = hasOverflow ? '#10b981' : isSelected ? '#22d3ee' : '#64748b'
|
||||||
|
const alpha = hasOverflow ? 1.0 : isSelected ? 1.0 : 0.5
|
||||||
|
|
||||||
|
// Draw arrow line
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.globalAlpha = alpha
|
||||||
|
ctx.lineWidth = width
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(start.x, start.y)
|
||||||
|
ctx.lineTo(end.x, end.y)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Draw arrowhead
|
||||||
|
drawArrowhead(ctx, start.x, start.y, end.x, end.y, color, width * 1.8)
|
||||||
|
|
||||||
|
// Draw percentage label at midpoint
|
||||||
|
const midX = (start.x + end.x) / 2
|
||||||
|
const midY = (start.y + end.y) / 2
|
||||||
|
|
||||||
|
// Background for label
|
||||||
|
ctx.globalAlpha = 0.8
|
||||||
|
ctx.fillStyle = '#1e293b'
|
||||||
|
const labelText = formatPercentage(allocation.percentage)
|
||||||
|
const textMetrics = ctx.measureText(labelText)
|
||||||
|
ctx.fillRect(midX - 2, midY - 18, textMetrics.width + 8, 20)
|
||||||
|
|
||||||
|
// Label text
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = 'bold 12px sans-serif'
|
||||||
|
ctx.fillText(labelText, midX + 2, midY - 3)
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear and render entire network
|
||||||
|
*/
|
||||||
|
export function renderNetwork(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
canvasWidth: number,
|
||||||
|
canvasHeight: number,
|
||||||
|
selectedAccountId: string | null = null,
|
||||||
|
selectedAllocationId: string | null = null
|
||||||
|
) {
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = '#0f172a'
|
||||||
|
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
|
||||||
|
|
||||||
|
// Draw allocations first (so they appear behind accounts)
|
||||||
|
network.allocations.forEach((allocation) => {
|
||||||
|
const sourceAccount = network.accounts.find((a) => a.id === allocation.sourceAccountId)
|
||||||
|
const targetAccount = network.accounts.find((a) => a.id === allocation.targetAccountId)
|
||||||
|
|
||||||
|
if (sourceAccount && targetAccount) {
|
||||||
|
renderAllocation(
|
||||||
|
ctx,
|
||||||
|
allocation,
|
||||||
|
sourceAccount,
|
||||||
|
targetAccount,
|
||||||
|
allocation.id === selectedAllocationId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draw accounts
|
||||||
|
network.accounts.forEach((account) => {
|
||||||
|
renderAccount(ctx, account, account.id === selectedAccountId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Draw network stats in corner
|
||||||
|
drawNetworkStats(ctx, network, canvasWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw network statistics in top-right corner
|
||||||
|
*/
|
||||||
|
function drawNetworkStats(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
network: FlowFundingNetwork,
|
||||||
|
canvasWidth: number
|
||||||
|
) {
|
||||||
|
const padding = 15
|
||||||
|
const lineHeight = 20
|
||||||
|
const x = canvasWidth - 200
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(30, 41, 59, 0.9)'
|
||||||
|
ctx.fillRect(x - 10, padding - 5, 210, lineHeight * 5 + 10)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#22d3ee'
|
||||||
|
ctx.font = 'bold 14px sans-serif'
|
||||||
|
ctx.fillText('Network Stats', x, padding + lineHeight * 0)
|
||||||
|
|
||||||
|
ctx.font = '12px monospace'
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.fillText(`Total Funds: ${formatCurrency(network.totalFunds)}`, x, padding + lineHeight * 1)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ef4444'
|
||||||
|
ctx.fillText(
|
||||||
|
`Shortfall: ${formatCurrency(network.totalShortfall)}`,
|
||||||
|
x,
|
||||||
|
padding + lineHeight * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#eab308'
|
||||||
|
ctx.fillText(`Capacity: ${formatCurrency(network.totalCapacity)}`, x, padding + lineHeight * 3)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#10b981'
|
||||||
|
ctx.fillText(`Overflow: ${formatCurrency(network.totalOverflow)}`, x, padding + lineHeight * 4)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
/**
|
||||||
|
* Sample Flow Funding networks for demonstration and testing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FlowFundingNetwork, FlowFundingAccount } from './types'
|
||||||
|
import {
|
||||||
|
updateAccountComputedProperties,
|
||||||
|
calculateNetworkTotals,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an account with computed properties
|
||||||
|
*/
|
||||||
|
function createAccount(data: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
balance: number
|
||||||
|
minThreshold: number
|
||||||
|
maxThreshold: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}): FlowFundingAccount {
|
||||||
|
return updateAccountComputedProperties({
|
||||||
|
...data,
|
||||||
|
width: data.width || 160,
|
||||||
|
height: data.height || 140,
|
||||||
|
status: 'deficit', // Will be computed
|
||||||
|
shortfall: 0, // Will be computed
|
||||||
|
capacity: 0, // Will be computed
|
||||||
|
overflow: 0, // Will be computed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 1: Simple Linear Flow (A → B → C)
|
||||||
|
* Demonstrates basic flow through a chain
|
||||||
|
*/
|
||||||
|
export const simpleLinearNetwork: FlowFundingNetwork = calculateNetworkTotals({
|
||||||
|
name: 'Simple Linear Flow',
|
||||||
|
accounts: [
|
||||||
|
createAccount({
|
||||||
|
id: 'alice',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 300,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'bob',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 50,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 400,
|
||||||
|
y: 200,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'carol',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 75,
|
||||||
|
maxThreshold: 250,
|
||||||
|
x: 700,
|
||||||
|
y: 200,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
allocations: [
|
||||||
|
{ id: 'a1', sourceAccountId: 'alice', targetAccountId: 'bob', percentage: 1.0 },
|
||||||
|
{ id: 'a2', sourceAccountId: 'bob', targetAccountId: 'carol', percentage: 1.0 },
|
||||||
|
],
|
||||||
|
totalFunds: 0,
|
||||||
|
totalShortfall: 0,
|
||||||
|
totalCapacity: 0,
|
||||||
|
totalOverflow: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 2: Mutual Aid Circle (A ↔ B ↔ C ↔ A)
|
||||||
|
* Demonstrates circular support network
|
||||||
|
*/
|
||||||
|
export const mutualAidCircle: FlowFundingNetwork = calculateNetworkTotals({
|
||||||
|
name: 'Mutual Aid Circle',
|
||||||
|
accounts: [
|
||||||
|
createAccount({
|
||||||
|
id: 'alice',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 50,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 400,
|
||||||
|
y: 100,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'bob',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 150,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 600,
|
||||||
|
y: 300,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'carol',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 250,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 200,
|
||||||
|
y: 300,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
allocations: [
|
||||||
|
{ id: 'a1', sourceAccountId: 'alice', targetAccountId: 'bob', percentage: 1.0 },
|
||||||
|
{ id: 'a2', sourceAccountId: 'bob', targetAccountId: 'carol', percentage: 1.0 },
|
||||||
|
{ id: 'a3', sourceAccountId: 'carol', targetAccountId: 'alice', percentage: 1.0 },
|
||||||
|
],
|
||||||
|
totalFunds: 0,
|
||||||
|
totalShortfall: 0,
|
||||||
|
totalCapacity: 0,
|
||||||
|
totalOverflow: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 3: Commons Pool Redistribution
|
||||||
|
* Everyone contributes to pool, pool redistributes equally
|
||||||
|
*/
|
||||||
|
export const commonsPool: FlowFundingNetwork = calculateNetworkTotals({
|
||||||
|
name: 'Commons Pool',
|
||||||
|
accounts: [
|
||||||
|
createAccount({
|
||||||
|
id: 'pool',
|
||||||
|
name: 'Commons Pool',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 0,
|
||||||
|
maxThreshold: 500,
|
||||||
|
x: 400,
|
||||||
|
y: 150,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'alice',
|
||||||
|
name: 'Alice',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 150,
|
||||||
|
y: 350,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'bob',
|
||||||
|
name: 'Bob',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 400,
|
||||||
|
y: 400,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'carol',
|
||||||
|
name: 'Carol',
|
||||||
|
balance: 0,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 650,
|
||||||
|
y: 350,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
allocations: [
|
||||||
|
// Contributors to pool
|
||||||
|
{ id: 'a1', sourceAccountId: 'alice', targetAccountId: 'pool', percentage: 1.0 },
|
||||||
|
{ id: 'a2', sourceAccountId: 'bob', targetAccountId: 'pool', percentage: 1.0 },
|
||||||
|
{ id: 'a3', sourceAccountId: 'carol', targetAccountId: 'pool', percentage: 1.0 },
|
||||||
|
// Pool redistributes
|
||||||
|
{ id: 'a4', sourceAccountId: 'pool', targetAccountId: 'alice', percentage: 0.33 },
|
||||||
|
{ id: 'a5', sourceAccountId: 'pool', targetAccountId: 'bob', percentage: 0.33 },
|
||||||
|
{ id: 'a6', sourceAccountId: 'pool', targetAccountId: 'carol', percentage: 0.34 },
|
||||||
|
],
|
||||||
|
totalFunds: 0,
|
||||||
|
totalShortfall: 0,
|
||||||
|
totalCapacity: 0,
|
||||||
|
totalOverflow: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example 4: Different States Demo
|
||||||
|
* Shows all four account states at once
|
||||||
|
*/
|
||||||
|
export const statesDemo: FlowFundingNetwork = calculateNetworkTotals({
|
||||||
|
name: 'Account States Demo',
|
||||||
|
accounts: [
|
||||||
|
createAccount({
|
||||||
|
id: 'deficit',
|
||||||
|
name: 'Deficit',
|
||||||
|
balance: 30,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'minimum',
|
||||||
|
name: 'Minimum',
|
||||||
|
balance: 100,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 350,
|
||||||
|
y: 100,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'healthy',
|
||||||
|
name: 'Healthy',
|
||||||
|
balance: 150,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 600,
|
||||||
|
y: 100,
|
||||||
|
}),
|
||||||
|
createAccount({
|
||||||
|
id: 'overflow',
|
||||||
|
name: 'Overflow',
|
||||||
|
balance: 250,
|
||||||
|
minThreshold: 100,
|
||||||
|
maxThreshold: 200,
|
||||||
|
x: 850,
|
||||||
|
y: 100,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
allocations: [
|
||||||
|
{ id: 'a1', sourceAccountId: 'overflow', targetAccountId: 'deficit', percentage: 1.0 },
|
||||||
|
],
|
||||||
|
totalFunds: 0,
|
||||||
|
totalShortfall: 0,
|
||||||
|
totalCapacity: 0,
|
||||||
|
totalOverflow: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sample networks
|
||||||
|
*/
|
||||||
|
export const sampleNetworks = {
|
||||||
|
simpleLinear: simpleLinearNetwork,
|
||||||
|
mutualAid: mutualAidCircle,
|
||||||
|
commonsPool: commonsPool,
|
||||||
|
statesDemo: statesDemo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get network by key
|
||||||
|
*/
|
||||||
|
export function getSampleNetwork(key: keyof typeof sampleNetworks): FlowFundingNetwork {
|
||||||
|
return sampleNetworks[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of network options for UI
|
||||||
|
*/
|
||||||
|
export const networkOptions = [
|
||||||
|
{ value: 'simpleLinear', label: 'Simple Linear Flow (A → B → C)' },
|
||||||
|
{ value: 'mutualAid', label: 'Mutual Aid Circle (A ↔ B ↔ C)' },
|
||||||
|
{ value: 'commonsPool', label: 'Commons Pool Redistribution' },
|
||||||
|
{ value: 'statesDemo', label: 'Account States Demo' },
|
||||||
|
] as const
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Type definitions for Threshold-Based Flow Funding
|
||||||
|
* These types model the academic paper's mathematical concepts
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AccountStatus = 'deficit' | 'minimum' | 'healthy' | 'overflow'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlowFundingAccount represents a participant in the network
|
||||||
|
* Each account has:
|
||||||
|
* - balance: current funds held
|
||||||
|
* - minThreshold: minimum viable funding (survival level)
|
||||||
|
* - maxThreshold: overflow point (beyond which funds redistribute)
|
||||||
|
*/
|
||||||
|
export interface FlowFundingAccount {
|
||||||
|
// Identity
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
|
||||||
|
// Financial State
|
||||||
|
balance: number
|
||||||
|
minThreshold: number
|
||||||
|
maxThreshold: number
|
||||||
|
|
||||||
|
// Visual Position (for canvas rendering)
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
|
||||||
|
// Computed properties (derived from balance vs thresholds)
|
||||||
|
status: AccountStatus
|
||||||
|
shortfall: number // max(0, minThreshold - balance)
|
||||||
|
capacity: number // max(0, maxThreshold - balance)
|
||||||
|
overflow: number // max(0, balance - maxThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation represents where overflow goes
|
||||||
|
* When source account exceeds maxThreshold, overflow flows to target
|
||||||
|
* based on allocation percentage
|
||||||
|
*/
|
||||||
|
export interface Allocation {
|
||||||
|
id: string
|
||||||
|
sourceAccountId: string
|
||||||
|
targetAccountId: string
|
||||||
|
percentage: number // 0.0 to 1.0 (e.g., 0.5 = 50%)
|
||||||
|
|
||||||
|
// Visual (calculated dynamically from account positions)
|
||||||
|
x1?: number
|
||||||
|
y1?: number
|
||||||
|
x2?: number
|
||||||
|
y2?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlowFundingNetwork represents the complete system
|
||||||
|
*/
|
||||||
|
export interface FlowFundingNetwork {
|
||||||
|
name: string
|
||||||
|
accounts: FlowFundingAccount[]
|
||||||
|
allocations: Allocation[]
|
||||||
|
|
||||||
|
// Computed network-level properties
|
||||||
|
totalFunds: number
|
||||||
|
totalShortfall: number
|
||||||
|
totalCapacity: number
|
||||||
|
totalOverflow: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlowParticle represents an animated particle flowing along an allocation
|
||||||
|
* Used to visualize fund transfers during redistribution
|
||||||
|
*/
|
||||||
|
export interface FlowParticle {
|
||||||
|
allocationId: string
|
||||||
|
progress: number // 0.0 to 1.0 along the path
|
||||||
|
amount: number // Funds being transferred
|
||||||
|
startTime: number // timestamp when particle was created
|
||||||
|
duration: number // milliseconds for animation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RedistributionStep captures one iteration of the overflow redistribution process
|
||||||
|
*/
|
||||||
|
export interface RedistributionStep {
|
||||||
|
iteration: number
|
||||||
|
overflows: Array<{ accountId: string; amount: number }>
|
||||||
|
deltas: Record<string, number> // accountId -> balance change
|
||||||
|
flowParticles: FlowParticle[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FundingStep represents a step in the funding round process
|
||||||
|
* Used for animation/visualization callbacks
|
||||||
|
*/
|
||||||
|
export type FundingStep =
|
||||||
|
| { type: 'initial-distribution'; amount: number }
|
||||||
|
| { type: 'overflow-redistribution' }
|
||||||
|
| { type: 'redistribution-step'; iteration: number; flowParticles: FlowParticle[] }
|
||||||
|
| { type: 'complete' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ValidationResult for network validation
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
errors: string[]
|
||||||
|
warnings: string[]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* Utility functions for Flow Funding calculations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FlowFundingAccount, AccountStatus, FlowFundingNetwork, Allocation } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate account status based on balance vs thresholds
|
||||||
|
*/
|
||||||
|
export function getAccountStatus(account: FlowFundingAccount): AccountStatus {
|
||||||
|
if (account.balance < account.minThreshold) return 'deficit'
|
||||||
|
if (account.balance >= account.maxThreshold) return 'overflow'
|
||||||
|
if (Math.abs(account.balance - account.minThreshold) < 0.01) return 'minimum'
|
||||||
|
return 'healthy'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate shortfall (funds needed to reach minimum)
|
||||||
|
*/
|
||||||
|
export function calculateShortfall(account: FlowFundingAccount): number {
|
||||||
|
return Math.max(0, account.minThreshold - account.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate capacity (funds that can be added before reaching maximum)
|
||||||
|
*/
|
||||||
|
export function calculateCapacity(account: FlowFundingAccount): number {
|
||||||
|
return Math.max(0, account.maxThreshold - account.balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate overflow (funds beyond maximum threshold)
|
||||||
|
*/
|
||||||
|
export function calculateOverflow(account: FlowFundingAccount): number {
|
||||||
|
return Math.max(0, account.balance - account.maxThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update computed properties on an account
|
||||||
|
*/
|
||||||
|
export function updateAccountComputedProperties(
|
||||||
|
account: FlowFundingAccount
|
||||||
|
): FlowFundingAccount {
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
status: getAccountStatus(account),
|
||||||
|
shortfall: calculateShortfall(account),
|
||||||
|
capacity: calculateCapacity(account),
|
||||||
|
overflow: calculateOverflow(account),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate network-level totals
|
||||||
|
*/
|
||||||
|
export function calculateNetworkTotals(network: FlowFundingNetwork): FlowFundingNetwork {
|
||||||
|
const totalFunds = network.accounts.reduce((sum, acc) => sum + acc.balance, 0)
|
||||||
|
const totalShortfall = network.accounts.reduce((sum, acc) => sum + acc.shortfall, 0)
|
||||||
|
const totalCapacity = network.accounts.reduce((sum, acc) => sum + acc.capacity, 0)
|
||||||
|
const totalOverflow = network.accounts.reduce((sum, acc) => sum + acc.overflow, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...network,
|
||||||
|
totalFunds,
|
||||||
|
totalShortfall,
|
||||||
|
totalCapacity,
|
||||||
|
totalOverflow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize allocations so they sum to 1.0
|
||||||
|
*/
|
||||||
|
export function normalizeAllocations(allocations: Allocation[]): Allocation[] {
|
||||||
|
// If only one allocation, it must be 100%
|
||||||
|
if (allocations.length === 1) {
|
||||||
|
return allocations.map(a => ({ ...a, percentage: 1.0 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = allocations.reduce((sum, a) => sum + a.percentage, 0)
|
||||||
|
|
||||||
|
// If total is 0, distribute equally
|
||||||
|
if (total === 0) {
|
||||||
|
const equalShare = 1.0 / allocations.length
|
||||||
|
return allocations.map((a) => ({
|
||||||
|
...a,
|
||||||
|
percentage: equalShare,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already normalized (within tolerance), return as-is
|
||||||
|
if (Math.abs(total - 1.0) < 0.0001) {
|
||||||
|
return allocations
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize by dividing by total
|
||||||
|
return allocations.map((a) => ({
|
||||||
|
...a,
|
||||||
|
percentage: a.percentage / total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get center point of an account (for arrow endpoints)
|
||||||
|
*/
|
||||||
|
export function getAccountCenter(account: FlowFundingAccount): { x: number; y: number } {
|
||||||
|
return {
|
||||||
|
x: account.x + account.width / 2,
|
||||||
|
y: account.y + account.height / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for rendering
|
||||||
|
*/
|
||||||
|
export function getStatusColor(status: AccountStatus, alpha: number = 1): string {
|
||||||
|
const colors = {
|
||||||
|
deficit: `rgba(239, 68, 68, ${alpha})`, // Red
|
||||||
|
minimum: `rgba(251, 191, 36, ${alpha})`, // Yellow
|
||||||
|
healthy: `rgba(99, 102, 241, ${alpha})`, // Blue
|
||||||
|
overflow: `rgba(16, 185, 129, ${alpha})`, // Green
|
||||||
|
}
|
||||||
|
return colors[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color as Tailwind class
|
||||||
|
*/
|
||||||
|
export function getStatusColorClass(status: AccountStatus): string {
|
||||||
|
const classes = {
|
||||||
|
deficit: 'text-red-400',
|
||||||
|
minimum: 'text-yellow-400',
|
||||||
|
healthy: 'text-blue-400',
|
||||||
|
overflow: 'text-green-400',
|
||||||
|
}
|
||||||
|
return classes[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format currency for display
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return amount.toFixed(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format percentage for display
|
||||||
|
*/
|
||||||
|
export function formatPercentage(decimal: number): string {
|
||||||
|
return `${Math.round(decimal * 100)}%`
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,11 @@
|
||||||
"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",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
|
|
@ -66,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"
|
||||||
|
|
|
||||||
807
pnpm-lock.yaml
807
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);
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
@ -11,7 +15,7 @@
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|
@ -19,9 +23,19 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue