Merge pull request #1 from LinuxIsCool/main

Add Threshold-based flow funding demos.
This commit is contained in:
Jeff Emmett 2025-11-27 10:47:22 -08:00 committed by GitHub
commit 4d47acf5c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 11984 additions and 53 deletions

276
.claude/DEMOS_DASHBOARD.md Normal file
View File

@ -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."*

View File

View File

@ -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**.

View File

@ -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
View File

@ -1,30 +1,238 @@
# 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.
[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?style=for-the-badge&logo=vercel)](https://vercel.com/jeff-emmetts-projects/v0-post-appitalism-website)
[![Built with v0](https://img.shields.io/badge/Built%20with-v0.app-black?style=for-the-badge)](https://v0.app/chat/s5q7XzkHh6S)
[![Deployed on Vercel](https://img.shields.io/badge/Deployed%20on-Vercel-black?style=for-the-badge&logo=vercel)](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).
Any changes you make to your deployed app will be automatically pushed to this repository from [v0.app](https://v0.app).
## Quick Start
```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
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)
2. Deploy your chats from the v0 interface
3. Changes are automatically pushed to this repository
4. Vercel deploys the latest version from this repository
1. Read [CANVAS_DEVELOPMENT_GUIDE.md](./CANVAS_DEVELOPMENT_GUIDE.md)
2. Understand the philosophy in `../../../CLAUDE.md`
3. Make changes that embody Post-Appitalism principles
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.

365
app/demos/page.tsx Normal file
View File

@ -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>
)
}

675
app/flow-v2/page.tsx Normal file
View File

@ -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>
)
}

1029
app/flowfunding/page.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,106 @@ import type React from "react"
import { useEffect, useRef, useState } from "react"
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"
// 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 {
id: string
type: "rectangle" | "ellipse" | "line" | "text"
type: "rectangle" | "ellipse" | "line" | "text" | "arrow"
x: number
y: number
width?: number
@ -18,6 +113,13 @@ interface Shape {
y2?: number
text?: 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() {
@ -54,6 +156,224 @@ export default function ItalismPage() {
const [currentShape, setCurrentShape] = useState<Partial<Shape> | null>(null)
const [selectedShape, setSelectedShape] = useState<string | null>(null)
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(() => {
const canvas = canvasRef.current
@ -70,6 +390,26 @@ export default function ItalismPage() {
ctx.fillStyle = "#0f172a"
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
shapes.forEach((shape) => {
ctx.strokeStyle = shape.color
@ -102,6 +442,23 @@ export default function ItalismPage() {
ctx.moveTo(shape.x, shape.y)
ctx.lineTo(shape.x2, shape.y2)
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
@ -110,8 +467,49 @@ export default function ItalismPage() {
ctx.lineWidth = 3
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 canvas = canvasRef.current
@ -122,14 +520,76 @@ export default function ItalismPage() {
const y = e.clientY - rect.top
if (tool === "select") {
// Find clicked shape
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
}
return false
})
// Find clicked shape (including arrows)
const clicked = shapes.find((shape) => isPointInShape(x, y, shape))
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") {
setIsDrawing(true)
setCurrentShape({
@ -143,8 +603,6 @@ export default function ItalismPage() {
}
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !currentShape) return
const canvas = canvasRef.current
if (!canvas) return
@ -152,27 +610,111 @@ export default function ItalismPage() {
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (currentShape.type === "rectangle") {
setCurrentShape({
...currentShape,
width: x - (currentShape.x || 0),
height: y - (currentShape.y || 0),
})
} else if (currentShape.type === "line") {
setCurrentShape({
...currentShape,
x2: x,
y2: y,
})
// Handle dragging selected shape
if (isDragging && selectedShape && tool === "select") {
setShapes(
shapes.map((shape) => {
if (shape.id === selectedShape) {
const newX = x - dragOffset.x
const newY = y - dragOffset.y
// For lines and arrows, also update the end points
if ((shape.type === "line" || shape.type === "arrow") && shape.x2 !== undefined && shape.y2 !== undefined) {
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 = () => {
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)
} else if (isDragging) {
// Save to history when dragging stops
saveToHistory(shapes)
}
setIsDrawing(false)
setIsDragging(false)
setArrowStartShape(null)
}
const toggleFullscreen = () => {
@ -263,8 +805,144 @@ export default function ItalismPage() {
</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 */}
<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
onClick={toggleFullscreen}
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"}
</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"
>
Clear Canvas

View File

@ -11,6 +11,14 @@ export const metadata: Metadata = {
title: "Project Interlay | Post-Appitalism",
description: "Weaving a post-appitalist future. Decomposing the data silos of capitalist business models.",
generator: "v0.app",
icons: {
icon: [
{
url: "data:image/svg+xml,<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({

671
app/tbff-flow/page.tsx Normal file
View File

@ -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>
)
}

778
app/tbff/page.tsx Normal file
View File

@ -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>
)
}

View File

@ -34,12 +34,16 @@ export function HeroSection() {
</p>
<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">
Explore the Vision
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
<Button size="lg" className="text-lg px-8 group" asChild>
<a href="/demos">
View Interactive Demos
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</a>
</Button>
<Button size="lg" variant="outline" className="text-lg px-8 bg-transparent">
Read the Research
<Button size="lg" variant="outline" className="text-lg px-8 bg-transparent" asChild>
<a href="#vision">
Explore the Vision
</a>
</Button>
</div>
</div>

464
lib/flow-funding/engine.ts Normal file
View File

@ -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),
}))
}

View File

@ -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)
}

View File

@ -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,
}
}

90
lib/flow-funding/types.ts Normal file
View File

@ -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[]
}

455
lib/flow-v2/engine-v2.ts Normal file
View File

@ -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
}
}

399
lib/flow-v2/scenarios-v2.ts Normal file
View File

@ -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)
}

117
lib/flow-v2/types.ts Normal file
View File

@ -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[]
}

404
lib/tbff-flow/README.md Normal file
View File

@ -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.*

267
lib/tbff-flow/algorithms.ts Normal file
View File

@ -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,
}
})
}

319
lib/tbff-flow/rendering.ts Normal file
View File

@ -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)
}

View File

@ -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]
}

90
lib/tbff-flow/types.ts Normal file
View File

@ -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
}

176
lib/tbff-flow/utils.ts Normal file
View File

@ -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
})
}

409
lib/tbff/README.md Normal file
View File

@ -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.

236
lib/tbff/algorithms.ts Normal file
View File

@ -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,
}
}

298
lib/tbff/rendering.ts Normal file
View File

@ -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)
}

265
lib/tbff/sample-networks.ts Normal file
View File

@ -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

110
lib/tbff/types.ts Normal file
View File

@ -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[]
}

151
lib/tbff/utils.ts Normal file
View File

@ -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)}%`
}

View File

@ -6,9 +6,11 @@
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"start": "next start"
"start": "next start",
"screenshots": "node scripts/capture-screenshots.mjs"
},
"dependencies": {
"@folkjs/propagators": "link:../folkjs/packages/propagators",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
@ -66,6 +68,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"puppeteer": "^24.31.0",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"

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

BIN
public/screenshots/tbff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

138
scripts/README.md Normal file
View File

@ -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)

78
scripts/capture-screenshots.mjs Executable file
View File

@ -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);

View File

@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./*"]
"@/*": [
"./*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}