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

+ šŸ’§ Flow Funding Demos +

+

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

+

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

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

{categoryInfo.title}

+

{categoryInfo.desc}

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

+ {demo.title} +

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

+ {demo.description} +

+ +
+
+ Key Features: +
+
+ {demo.features.slice(0, 3).map((feature, idx) => ( + + {feature} + + ))} + {demo.features.length > 3 && ( + + +{demo.features.length - 3} more + + )} +
+
+ +
+ {demo.type} + + Launch Demo → + +
+
+
+ ))} +
+
+ ) + })} + + {/* Footer */} + +
+
+ ) +} diff --git a/app/flow-v2/page.tsx b/app/flow-v2/page.tsx new file mode 100644 index 0000000..fb561ba --- /dev/null +++ b/app/flow-v2/page.tsx @@ -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(linearChainV2) + + // Node state (with adjustable external inflows) + const [nodes, setNodes] = useState(() => + cloneNodes(currentScenario.nodes) + ) + + // Network state (calculated) + const [network, setNetwork] = useState(null) + + // Animation state + const [particles, setParticles] = useState([]) + const [isPlaying, setIsPlaying] = useState(true) + const [simulationTime, setSimulationTime] = useState(0) + + // UI state + const [selectedNodeId, setSelectedNodeId] = useState(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 ( + + {/* 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 ( + + {/* Edge line */} + + + {/* Flow label */} + + ${edge.flowRate.toFixed(0)}/mo + + + ) + })} + + {/* 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 ( + + ) + })} + + {/* 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 ( + setSelectedNodeId(node.id)} + className="cursor-pointer" + > + {/* Selection ring */} + {isSelected && ( + + )} + + {/* Node circle */} + + + {/* Node label */} + + {node.name} + + + {/* Zone indicator */} + + {zone} + + + {/* Retention rate */} + + +${retention.toFixed(0)}/mo + + + ) + })} + + {/* Overflow node */} + {network.overflowNode && ( + + + + Overflow + + + ${network.overflowNode.totalInflow.toFixed(0)}/mo + + + )} + + {/* Arrow marker definition */} + + + + + + + ) + }, [network, particles, selectedNodeId, getNodePos, getZoneColor, currentScenario]) + + return ( +
+
+ {/* Header */} +
+

+ Flow Funding V2 +

+

+ Continuous flow dynamics with progressive outflow zones +

+
+ + {/* Controls */} +
+ {/* Scenario selector */} +
+ + +
+ + {/* Play/pause */} +
+ + +
+ + {/* Metrics toggle */} +
+ + +
+ + {/* Simulation time */} +
+ +
+ {simulationTime.toFixed(1)}s +
+
+
+ + {/* Scenario description */} +
+

{currentScenario.description}

+
+ + {/* Main layout */} +
+ {/* Network visualization */} +
+ {renderNetwork} +
+ + {/* Control panel */} +
+

External Inflows

+ + {/* 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 ( +
+ {/* Node name and zone */} +
+ {node.name} + + {zone} + +
+ + {/* External inflow slider */} +
+ + + handleInflowChange(node.id, parseFloat(e.target.value)) + } + className="w-full" + /> +
+ + {/* Thresholds */} +
+
Min: ${node.minThreshold}/mo
+
Max: ${node.maxThreshold}/mo
+
+ + {/* Flow metrics */} + {showMetrics && networkNode && ( +
+
+ Total In: + + ${(networkNode.totalInflow || 0).toFixed(0)}/mo + +
+
+ Total Out: + + ${(networkNode.totalOutflow || 0).toFixed(0)}/mo + +
+
+ Retained: + + $ + {( + (networkNode.totalInflow || 0) - + (networkNode.totalOutflow || 0) + ).toFixed(0)} + /mo + +
+
+ Balance: + + ${(networkNode.balance || 0).toFixed(0)} + +
+
+ )} +
+ ) + })} + + {/* Network totals */} + {showMetrics && network && ( +
+

Network Totals

+
+
+ External Inflow: + + ${network.totalExternalInflow.toFixed(0)}/mo + +
+
+ Network Needs: + + ${network.totalNetworkNeeds.toFixed(0)}/mo + +
+
+ Network Capacity: + + ${network.totalNetworkCapacity.toFixed(0)}/mo + +
+ {network.overflowNode && ( +
+ Overflow: + + ${network.overflowNode.totalInflow.toFixed(0)}/mo + +
+ )} +
+
+ Converged: + + {network.converged ? 'āœ“ Yes' : 'āœ— No'} + +
+
+ {network.iterations} iterations +
+
+
+
+ )} +
+
+ + {/* Legend */} +
+

Flow Zones

+
+
+
+
+
Deficit Zone
+
+ Inflow below min threshold. Keep everything (0% outflow). +
+
+
+
+
+
+
Building Zone
+
+ Between min and max. Progressive sharing based on capacity. +
+
+
+
+
+
+
Capacity Zone
+
+ Above max threshold. Redirect 100% of excess. +
+
+
+
+
+
+
+ ) +} diff --git a/app/flowfunding/page.tsx b/app/flowfunding/page.tsx new file mode 100644 index 0000000..86da74a --- /dev/null +++ b/app/flowfunding/page.tsx @@ -0,0 +1,1029 @@ +'use client' + +/** + * Flow Funding Demo - Interactive Mode + * + * Enhanced with: + * - Animated flow particles + * - Distribution timeline + * - Auto-play with speed controls + * - INTERACTIVE: Click accounts to add targeted funding + */ + +import { useState, useEffect, useRef } from 'react' +import { + cloneAccounts, + runDistribution, +} from '@/lib/flow-funding/engine' +import { runTargetedDistribution } from '@/lib/flow-funding/targeted' +import type { Account, DistributionResult } from '@/lib/flow-funding/types' +import { getAccountState } from '@/lib/flow-funding/types' +import { + ALL_SCENARIOS, + getScenario, + type Scenario, +} from '@/lib/flow-funding/scenarios' + +// Flow particle for animation +interface FlowParticle { + id: string + fromX: number + fromY: number + toX: number + toY: number + progress: number // 0 to 1 + amount: number + color: string +} + +type FundingMode = 'global' | 'interactive' + +export default function FlowFundingPage() { + const [selectedScenarioId, setSelectedScenarioId] = useState( + 'mutual-aid-circle' + ) + const [funding, setFunding] = useState(1500) + const [targetedAmount, setTargetedAmount] = useState(500) + const [result, setResult] = useState(null) + const [currentIteration, setCurrentIteration] = useState(0) + const [animationSpeed, setAnimationSpeed] = useState(1) + const [particles, setParticles] = useState([]) + const [autoPlay, setAutoPlay] = useState(false) + const [fundingMode, setFundingMode] = useState('interactive') + + // Interactive mode state + const [selectedAccountId, setSelectedAccountId] = useState(null) + const [currentAccounts, setCurrentAccounts] = useState([]) + + const scenario = getScenario(selectedScenarioId) + const animationFrameRef = useRef() + + // Initialize current accounts when scenario changes + useEffect(() => { + if (scenario) { + setCurrentAccounts(cloneAccounts(scenario.accounts)) + setSelectedAccountId(null) + } + }, [selectedScenarioId]) + + // Auto-play through iterations + useEffect(() => { + if (!autoPlay || !result) return + + const interval = setInterval(() => { + setCurrentIteration(prev => { + if (prev >= result.iterations.length - 1) { + setAutoPlay(false) + return prev + } + return prev + 1 + }) + }, 1000 / animationSpeed) + + return () => clearInterval(interval) + }, [autoPlay, result, animationSpeed]) + + // Animate particles when iteration changes + useEffect(() => { + if (!result || !scenario) return + + const iteration = result.iterations[currentIteration] + if (!iteration || iteration.flows.size === 0) { + setParticles([]) + return + } + + // Create particles for each flow + const newParticles: FlowParticle[] = [] + let particleId = 0 + + iteration.flows.forEach((amount, flowKey) => { + const [sourceId, targetId] = flowKey.split('->') + const sourcePos = scenario.layout.get(sourceId) + const targetPos = scenario.layout.get(targetId) + + if (!sourcePos || !targetPos || amount <= 0) return + + const numParticles = Math.min(3, Math.max(1, Math.floor(amount / 100))) + + for (let i = 0; i < numParticles; i++) { + newParticles.push({ + id: `${flowKey}-${particleId++}`, + fromX: sourcePos.x, + fromY: sourcePos.y, + toX: targetPos.x, + toY: targetPos.y, + progress: i * (1 / numParticles), + amount: amount / numParticles, + color: '#60a5fa', + }) + } + }) + + setParticles(newParticles) + + // Animate particles + let startTime: number | null = null + const duration = 1500 / animationSpeed + + const animate = (timestamp: number) => { + if (!startTime) startTime = timestamp + const elapsed = timestamp - startTime + const progress = Math.min(elapsed / duration, 1) + + setParticles(prev => + prev.map(p => ({ + ...p, + progress: Math.min(p.progress + progress, 1), + })) + ) + + if (progress < 1) { + animationFrameRef.current = requestAnimationFrame(animate) + } else { + setParticles([]) + } + } + + animationFrameRef.current = requestAnimationFrame(animate) + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + } + }, [currentIteration, result, scenario, animationSpeed]) + + const handleScenarioChange = (scenarioId: string) => { + setSelectedScenarioId(scenarioId) + const newScenario = getScenario(scenarioId) + if (newScenario) { + setFunding(newScenario.suggestedFunding) + setCurrentAccounts(cloneAccounts(newScenario.accounts)) + } + setResult(null) + setCurrentIteration(0) + setAutoPlay(false) + setParticles([]) + setSelectedAccountId(null) + } + + const handleDistribute = () => { + if (!scenario) return + + const accounts = cloneAccounts(scenario.accounts) + const distributionResult = runDistribution(accounts, funding, { + verbose: true, + maxIterations: 100, + epsilon: 0.01, + }) + + setCurrentAccounts(accounts) + setResult(distributionResult) + setCurrentIteration(0) + setAutoPlay(true) + + console.log('Distribution complete:', distributionResult) + } + + const handleAddTargetedFunding = () => { + if (!selectedAccountId) return + + // Add funding to selected account + const accounts = cloneAccounts(currentAccounts) + const targetAccount = accounts.find(a => a.id === selectedAccountId) + + if (!targetAccount) return + + targetAccount.balance += targetedAmount + + console.log(`\nšŸ’° Adding $${targetedAmount} to ${targetAccount.name}`) + console.log(`New balance: $${targetAccount.balance.toFixed(2)}`) + + // Run targeted distribution + const distributionResult = runTargetedDistribution(accounts, { + verbose: true, + maxIterations: 100, + epsilon: 0.01, + }) + + setCurrentAccounts(accounts) + setResult(distributionResult) + setCurrentIteration(0) + setAutoPlay(true) + } + + const handleAccountClick = (accountId: string) => { + if (fundingMode === 'interactive') { + setSelectedAccountId(accountId) + } + } + + const handleReset = () => { + if (scenario) { + setCurrentAccounts(cloneAccounts(scenario.accounts)) + } + setResult(null) + setCurrentIteration(0) + setAutoPlay(false) + setParticles([]) + setSelectedAccountId(null) + } + + const handleTimelineClick = (iteration: number) => { + setCurrentIteration(iteration) + setAutoPlay(false) + } + + const handleNextStep = () => { + if (!result || currentIteration >= result.iterations.length - 1) return + setCurrentIteration(prev => prev + 1) + } + + const handlePrevStep = () => { + if (currentIteration <= 0) return + setCurrentIteration(prev => prev - 1) + } + + // Get current state for visualization + const getCurrentBalances = (): Map => { + if (!result) { + return new Map(currentAccounts.map(a => [a.id, a.balance])) + } + return result.iterations[currentIteration]?.balances || result.finalBalances + } + + const getCurrentOverflows = (): Map => { + if (!result || currentIteration < 0) { + return new Map() + } + return result.iterations[currentIteration]?.overflows || new Map() + } + + return ( +
+
+ {/* Header */} +
+

+ Flow Funding Demo +

+

+ {fundingMode === 'interactive' + ? 'šŸ’” Click any account to add funding and watch it propagate through the network' + : 'Watch resources flow through the network. Thresholds create circulation.' + } +

+
+ +
+ {/* Left Panel: Controls */} +
+ {/* Mode Selector */} +
+

Funding Mode

+
+ + +
+

+ {fundingMode === 'interactive' + ? 'Click accounts to add funding directly' + : 'Distribute funding equally to all accounts' + } +

+
+ + {/* Scenario Selector */} +
+

Select Scenario

+ + + {scenario && ( +

+ {scenario.description} +

+ )} +
+ + {/* Funding Controls */} + {fundingMode === 'global' ? ( +
+

Global Funding

+
+
+ + setFunding(Number(e.target.value))} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + min="0" + step="100" + /> +
+ + + + +
+
+ ) : ( +
+

Interactive Funding

+
+ {selectedAccountId ? ( + <> +
+
+ Selected Account +
+
+ {currentAccounts.find(a => a.id === selectedAccountId)?.name} +
+
+ Current: ${currentAccounts.find(a => a.id === selectedAccountId)?.balance.toFixed(0)} +
+
+ +
+ + setTargetedAmount(Number(e.target.value))} + className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:ring-2 focus:ring-blue-500" + min="0" + step="100" + /> +
+ + + + + + ) : ( +
+
šŸ‘†
+
+ Click an account on the network +
+
+ to add funding and watch it propagate +
+
+ )} + + +
+
+ )} + + {/* Playback Controls */} + {result && ( +
+

+ Playback Controls +

+
+
+ Iteration {currentIteration + 1} of {result.iterations.length} +
+ + + +
+ +
+ {[1, 2, 5].map(speed => ( + + ))} +
+
+ +
+ + +
+ + {result.converged ? ( +
+ āœ“ Converged in {result.iterationCount} iterations +
+ ) : ( +
+ ⚠ Did not converge +
+ )} +
+
+ )} + + {/* Legend */} +
+

Account States

+
+
+
+ + Below Minimum + +
+
+
+ + Sustainable + +
+
+
+ At Maximum +
+
+
+ + Overflowing + +
+ {fundingMode === 'interactive' && ( +
+
+ + Selected (click to target) + +
+ )} +
+
+
+ + {/* Right Panel: Visualization */} +
+
+

Network Visualization

+ + {scenario && ( + + )} +
+ + {/* Timeline */} + {result && ( +
+

Distribution Timeline

+ +
+ )} + + {/* Results Table */} + {result && ( +
+

+ Distribution Results +

+ +
+ )} +
+
+
+
+ ) +} + +/** + * Network Visualization Component with Animated Particles and Click Handlers + */ +function NetworkVisualization({ + scenario, + balances, + overflows, + flows, + particles, + selectedAccountId, + onAccountClick, + interactiveMode, +}: { + scenario: Scenario + balances: Map + overflows: Map + flows: Map + particles: FlowParticle[] + selectedAccountId: string | null + onAccountClick: (accountId: string) => void + interactiveMode: boolean +}) { + const { accounts, layout } = scenario + + const padding = 80 + const maxX = Math.max(...Array.from(layout.values()).map(p => p.x)) + padding + const maxY = Math.max(...Array.from(layout.values()).map(p => p.y)) + padding + + return ( + + + + + + + + + + + + + + + + + + + {/* Draw arrows */} + {accounts.map(account => { + const sourcePos = layout.get(account.id) + if (!sourcePos) return null + + return Array.from(account.allocations.entries()).map( + ([targetId, percentage]) => { + const targetPos = layout.get(targetId) + if (!targetPos) return null + + const flowKey = `${account.id}->${targetId}` + const flowAmount = flows.get(flowKey) || 0 + const isActive = flowAmount > 0 + + return ( + + + + {percentage}% + + {isActive && ( + + ${flowAmount.toFixed(0)} + + )} + + ) + } + ) + })} + + {/* Draw animated particles */} + {particles.map(particle => { + const x = particle.fromX + (particle.toX - particle.fromX) * particle.progress + const y = particle.fromY + (particle.toY - particle.fromY) * particle.progress + const size = Math.max(4, Math.min(8, particle.amount / 50)) + + return ( + + ) + })} + + {/* Draw accounts */} + {accounts.map(account => { + const pos = layout.get(account.id) + if (!pos) return null + + const balance = balances.get(account.id) || account.balance + const overflow = overflows.get(account.id) || 0 + const state = getAccountState( + balance, + account.minThreshold, + account.maxThreshold, + overflow > 0 + ) + + const isSelected = selectedAccountId === account.id + + const stateColors = { + 'below-minimum': '#ef4444', + sustainable: '#eab308', + 'at-maximum': '#22c55e', + overflowing: '#3b82f6', + } + + const color = isSelected ? '#a855f7' : stateColors[state] + + return ( + onAccountClick(account.id)} + style={{ cursor: interactiveMode ? 'pointer' : 'default' }} + > + {/* Selection ring */} + {isSelected && ( + + + + )} + + {/* Account circle */} + + + {/* Account name */} + + {account.name} + + + {/* Balance */} + + ${balance.toFixed(0)} + + + {/* Thresholds */} + + ({account.minThreshold}–{account.maxThreshold}) + + + ) + })} + + ) +} + +/** + * Distribution Timeline Component + */ +function DistributionTimeline({ + result, + currentIteration, + onIterationClick, +}: { + result: DistributionResult + currentIteration: number + onIterationClick: (iteration: number) => void +}) { + const maxOverflow = Math.max( + ...result.iterations.map(iter => iter.totalOverflow), + 1 + ) + + return ( +
+
+ Click any iteration to jump to that state + + Total overflow decreases over time → + +
+ +
+
+ {result.iterations.map((iter, index) => { + const isCurrent = index === currentIteration + const dotSize = Math.max( + 8, + Math.min(20, (iter.totalOverflow / maxOverflow) * 20) + ) + + return ( + + ) + })} +
+
+ +
+
+ Start:{' '} + ${result.iterations[0]?.totalOverflow.toFixed(2)} +
+
+ Current:{' '} + ${result.iterations[currentIteration]?.totalOverflow.toFixed(2) || '0.00'} +
+
+ End:{' '} + ${result.iterations[result.iterations.length - 1]?.totalOverflow.toFixed(2)} +
+
+
+ ) +} + +/** + * Results Table Component + */ +function ResultsTable({ + scenario, + result, + currentIteration, +}: { + scenario: Scenario + result: DistributionResult + currentIteration: number +}) { + const iteration = result.iterations[currentIteration] + const balances = iteration?.balances || result.finalBalances + + return ( +
+ + + + + + + + + + + + + {scenario.accounts.map(account => { + const initial = result.initialBalances.get(account.id) || 0 + const current = balances.get(account.id) || 0 + const change = current - initial + const overflow = iteration?.overflows.get(account.id) || 0 + const state = getAccountState( + current, + account.minThreshold, + account.maxThreshold, + overflow > 0 + ) + + const stateLabels = { + 'below-minimum': 'Below Min', + sustainable: 'Sustainable', + 'at-maximum': 'At Max', + overflowing: 'Overflowing', + } + + const stateColors = { + 'below-minimum': 'text-red-400', + sustainable: 'text-yellow-400', + 'at-maximum': 'text-green-400', + overflowing: 'text-blue-400', + } + + return ( + + + + + + + + + ) + })} + +
+ Account + + Initial + + Current + + Change + + Min/Max + + State +
{account.name} + ${initial.toFixed(2)} + + ${current.toFixed(2)} + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {change >= 0 ? '+' : ''}${change.toFixed(2)} + + ${account.minThreshold} / ${account.maxThreshold} + + {stateLabels[state]} +
+
+ ) +} diff --git a/app/italism/page.tsx b/app/italism/page.tsx index 3a06384..07f1a8d 100644 --- a/app/italism/page.tsx +++ b/app/italism/page.tsx @@ -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 | null>(null) const [selectedShape, setSelectedShape] = useState(null) const [isFullscreen, setIsFullscreen] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }) + const [arrowStartShape, setArrowStartShape] = useState(null) + const [propagators, setPropagators] = useState>(new Map()) + const [editingArrow, setEditingArrow] = useState(null) + const [eventTargets, setEventTargets] = useState>(new Map()) + + // Undo/Redo state - using useRef to avoid stale closure issues + const historyRef = useRef([]) + 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) => { 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) => { - 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() { + {/* Arrow Expression Editor */} + {selectedShape && shapes.find((s) => s.id === selectedShape)?.type === "arrow" && ( +
+

Live Arrow Properties

+ {(() => { + 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 ( +
+
+ From:{" "} + {sourceShape?.text || sourceShape?.id || "None"} +
+
+ To:{" "} + {targetShape?.text || targetShape?.id || "None"} +
+ {arrow.sourceShapeId && arrow.targetShapeId && ( + <> +
+ + { + 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" + /> +
+ + + )} +
+ ) + })()} +
+ )} + + {/* Shape Value Editor */} + {selectedShape && shapes.find((s) => s.id === selectedShape)?.type !== "arrow" && ( +
+

Shape Properties

+ {(() => { + const shape = shapes.find((s) => s.id === selectedShape) + if (!shape) return null + + return ( +
+
+ + { + 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" + /> +
+
+ Arrows connected from this shape will propagate this value. +
+
+ ) + })()} +
+ )} + {/* Actions */}
+
+ + +
+ +
+ + + {/* Network Selector */} +
+

Select Network

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

{network.name}

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

šŸ’§ Set Flow Input

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

Edit Allocation

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

Node Details

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

Legend

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

+ Flow-Based Model +

+
    +
  • Click node to select and set flow
  • +
  • Use Create Arrow to draw allocations
  • +
  • Watch flows propagate in real-time
  • +
  • Press Space to pause/play animation
  • +
  • Overflow sink appears automatically if needed
  • +
+
+ + + + ) +} diff --git a/app/tbff/page.tsx b/app/tbff/page.tsx new file mode 100644 index 0000000..549e5eb --- /dev/null +++ b/app/tbff/page.tsx @@ -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(null) + const [network, setNetwork] = useState(sampleNetworks.statesDemo) + const [selectedAccountId, setSelectedAccountId] = useState(null) + const [selectedAllocationId, setSelectedAllocationId] = useState(null) + const [selectedNetworkKey, setSelectedNetworkKey] = useState('statesDemo') + const [tool, setTool] = useState('select') + const [allocationSourceId, setAllocationSourceId] = useState(null) + const [isDragging, setIsDragging] = useState(false) + const [draggedAccountId, setDraggedAccountId] = useState(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) => { + 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) => { + 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) => { + 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 ( +
+ {/* Header */} +
+
+

+ Threshold-Based Flow Funding +

+

+ Milestone 3: Initial Distribution +

+
+ + ← Back to Home + +
+ + {/* Main Content */} +
+ {/* Canvas */} +
+ + + {/* Tool indicator */} + {allocationSourceId && ( +
+ Click target account to create allocation +
+ )} +
+ + {/* Sidebar */} +
+ {/* Tools */} +
+

Tools

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

Select Network

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

{network.name}

+
+
+ Accounts: + {network.accounts.length} +
+
+ Allocations: + {network.allocations.length} +
+
+ Total Funds: + {formatCurrency(network.totalFunds)} +
+
+ Shortfall: + {formatCurrency(network.totalShortfall)} +
+
+ Capacity: + {formatCurrency(network.totalCapacity)} +
+
+ Overflow: + {formatCurrency(network.totalOverflow)} +
+
+
+ + {/* Funding Controls */} +
+

šŸ’° Add Funding

+
+
+ + setFundingAmount(parseFloat(e.target.value) || 0)} + className="w-full px-3 py-2 bg-slate-700 rounded text-sm" + min="0" + step="100" + /> +
+ +
+ + {/* Distribution Summary */} + {lastDistribution && ( +
+
+
+ Distributed: + + {formatCurrency(lastDistribution.totalDistributed)} + +
+
+ Accounts Changed: + {lastDistribution.accountsChanged} +
+ {lastDistribution.changes.length > 0 && ( +
+
Changes:
+ {lastDistribution.changes.map((change) => ( +
+ {change.name} + + +{formatCurrency(change.delta)} + +
+ ))} +
+ )} +
+
+ )} +
+ + {/* Selected Allocation Editor */} + {selectedAllocation && ( +
+

Edit Allocation

+
+
+ From: + + {network.accounts.find(a => a.id === selectedAllocation.sourceAccountId)?.name} + +
+
+ To: + + {network.accounts.find(a => a.id === selectedAllocation.targetAccountId)?.name} + +
+
+ + {selectedAllocationSiblings.length === 1 ? ( +
+ Single allocation must be 100%. Create additional allocations to split overflow. +
+ ) : ( + <> + + updateAllocationPercentage( + selectedAllocation.id, + parseFloat(e.target.value) / 100 + ) + } + className="w-full" + /> +
+ Note: Percentages auto-normalize with other allocations from same source +
+ + )} +
+ +
+
+ )} + + {/* Selected Account Details */} + {selectedAccount && ( +
+

Account Details

+
+
+ Name: + {selectedAccount.name} +
+
+ Status: + + {selectedAccount.status.toUpperCase()} + +
+
+
+ Balance: + + {formatCurrency(selectedAccount.balance)} + +
+
+ Min Threshold: + + {formatCurrency(selectedAccount.minThreshold)} + +
+
+ Max Threshold: + + {formatCurrency(selectedAccount.maxThreshold)} + +
+
+ + {/* Outgoing Allocations */} + {outgoingAllocations.length > 0 && ( +
+
Outgoing Allocations:
+ {outgoingAllocations.map((alloc) => { + const target = network.accounts.find(a => a.id === alloc.targetAccountId) + return ( +
setSelectedAllocationId(alloc.id)} + > + → {target?.name} + + {Math.round(alloc.percentage * 100)}% + +
+ ) + })} +
+ )} +
+
+ )} + + {/* Account List */} +
+

All Accounts

+
+ {network.accounts.map((acc) => ( + + ))} +
+
+ + {/* Legend */} +
+

Legend

+
+
+
+ Deficit - Below minimum threshold +
+
+
+ Minimum - At minimum threshold +
+
+
+ Healthy - Between thresholds +
+
+
+ Overflow - Above maximum threshold +
+
+
+ + {/* Instructions */} +
+

+ Milestone 3: Initial Distribution +

+
    +
  • Add funding to distribute across accounts
  • +
  • Drag accounts to reposition them
  • +
  • Use Create Arrow tool to draw allocations
  • +
  • Click arrow to edit percentage
  • +
  • Press Delete to remove allocation
  • +
  • Check console for distribution logs
  • +
+
+
+
+
+ ) +} diff --git a/components/hero-section.tsx b/components/hero-section.tsx index 8536543..411c6a8 100644 --- a/components/hero-section.tsx +++ b/components/hero-section.tsx @@ -34,12 +34,16 @@ export function HeroSection() {

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