Initial commit: FlowFi website - flowfi.network
7-section narrative journey with interactive Sankey diagrams, flow sandbox (@xyflow/react), and animated pipe visualizations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
6836108065
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# env files
|
||||
.env*.local
|
||||
.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/out /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-void: #0a0a0a;
|
||||
--color-nothing: #1a1a2e;
|
||||
--color-less: #16213e;
|
||||
--color-anti-green: #e94560;
|
||||
--color-flow-green: #2dd4bf;
|
||||
--color-zen: #f5f5dc;
|
||||
--color-sloth: #c3b091;
|
||||
|
||||
--font-family-mono: "Courier New", monospace;
|
||||
--font-family-marker: var(--font-marker), "Permanent Marker", cursive;
|
||||
--font-family-caveat: var(--font-caveat), "Caveat", cursive;
|
||||
}
|
||||
|
||||
/* Pipe flow animation — core effect */
|
||||
@keyframes pipe-flow {
|
||||
0% { stroke-dashoffset: 24; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes pipe-flow-fast {
|
||||
0% { stroke-dashoffset: 24; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes pipe-flow-reverse {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: 24; }
|
||||
}
|
||||
|
||||
.animate-pipe-flow {
|
||||
stroke-dasharray: 8 4;
|
||||
animation: pipe-flow 1.5s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pipe-flow-fast {
|
||||
stroke-dasharray: 8 4;
|
||||
animation: pipe-flow 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pipe-flow-slow {
|
||||
stroke-dasharray: 8 4;
|
||||
animation: pipe-flow 2.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Particle travel along path */
|
||||
@keyframes travel-path {
|
||||
0% { offset-distance: 0%; opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
95% { opacity: 1; }
|
||||
100% { offset-distance: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-particle {
|
||||
offset-rotate: 0deg;
|
||||
animation: travel-path var(--duration, 3s) linear infinite;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
/* Water distortion */
|
||||
@keyframes turbulence {
|
||||
0% { }
|
||||
50% { }
|
||||
100% { }
|
||||
}
|
||||
|
||||
/* Scroll reveal */
|
||||
@keyframes reveal-up {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Breathing / pulsing */
|
||||
@keyframes breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
}
|
||||
|
||||
@keyframes slow-pulse {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
50% { transform: translateX(20px); }
|
||||
}
|
||||
|
||||
@keyframes flow-gradient {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
0% { opacity: 0.6; transform: translateY(0) rotate(0deg); }
|
||||
100% { opacity: 0; transform: translateY(-200px) rotate(10deg); }
|
||||
}
|
||||
|
||||
.animate-breathe { animation: breathe 6s ease-in-out infinite; }
|
||||
.animate-slow-pulse { animation: slow-pulse 8s ease-in-out infinite; }
|
||||
.animate-drift { animation: drift 12s ease-in-out infinite; }
|
||||
.animate-flow-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: flow-gradient 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Wobble rotations */
|
||||
.wobble-1 { transform: rotate(-1.5deg); }
|
||||
.wobble-2 { transform: rotate(0.8deg); }
|
||||
.wobble-3 { transform: rotate(-0.5deg); }
|
||||
.wobble-4 { transform: rotate(1.2deg); }
|
||||
|
||||
/* Scrawl underlines */
|
||||
.scrawl-underline {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.scrawl-underline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -4%;
|
||||
bottom: -4px;
|
||||
width: 108%;
|
||||
height: 4px;
|
||||
background: var(--color-flow-green);
|
||||
transform: rotate(-0.5deg) scaleX(0.95);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Sandbox node styles */
|
||||
.flow-node {
|
||||
border: 2px solid rgba(245, 245, 220, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
background: rgba(26, 26, 46, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.flow-node:hover {
|
||||
border-color: var(--color-flow-green);
|
||||
}
|
||||
|
||||
/* Disable feTurbulence on mobile for performance */
|
||||
@media (max-width: 768px) {
|
||||
.water-distortion {
|
||||
filter: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* React Flow overrides */
|
||||
.react-flow__background {
|
||||
background-color: var(--color-void) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls button {
|
||||
background-color: var(--color-nothing) !important;
|
||||
border-color: rgba(245, 245, 220, 0.1) !important;
|
||||
color: var(--color-zen) !important;
|
||||
fill: var(--color-zen) !important;
|
||||
}
|
||||
|
||||
.react-flow__controls button:hover {
|
||||
background-color: var(--color-less) !important;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background-color: var(--color-void) !important;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { Metadata } from 'next'
|
||||
import { Permanent_Marker, Caveat } from 'next/font/google'
|
||||
import './globals.css'
|
||||
|
||||
const marker = Permanent_Marker({
|
||||
weight: '400',
|
||||
subsets: ['latin'],
|
||||
variable: '--font-marker',
|
||||
})
|
||||
|
||||
const caveat = Caveat({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-caveat',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FlowFi — Everything Flows',
|
||||
description: 'Finance as metabolism, not mechanism. Pre-distributive. Re-distributive. Naturally.',
|
||||
openGraph: {
|
||||
title: 'FlowFi — Everything Flows',
|
||||
description: 'Finance as metabolism, not mechanism. Pre-distributive. Re-distributive. Naturally.',
|
||||
url: 'https://flowfi.network',
|
||||
siteName: 'FlowFi',
|
||||
type: 'website',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`bg-void text-zen font-mono antialiased ${marker.variable} ${caveat.variable}`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import HeroSection from '@/components/sections/HeroSection'
|
||||
import NatureFlowsSection from '@/components/sections/NatureFlowsSection'
|
||||
import ExtractivePipeSection from '@/components/sections/ExtractivePipeSection'
|
||||
import RegenerativeSection from '@/components/sections/RegenerativeSection'
|
||||
import TransitionSection from '@/components/sections/TransitionSection'
|
||||
import SandboxSection from '@/components/sections/SandboxSection'
|
||||
import CTASection from '@/components/sections/CTASection'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main>
|
||||
<HeroSection />
|
||||
<NatureFlowsSection />
|
||||
<ExtractivePipeSection />
|
||||
<RegenerativeSection />
|
||||
<TransitionSection />
|
||||
<SandboxSection />
|
||||
<CTASection />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useState, useMemo } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Connection,
|
||||
type Node,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import SourceNode from './nodes/SourceNode'
|
||||
import PipeNode from './nodes/PipeNode'
|
||||
import SinkNode from './nodes/SinkNode'
|
||||
import AnimatedPipeEdge from './edges/AnimatedPipeEdge'
|
||||
import FlowToolbar from './FlowToolbar'
|
||||
import { presets } from './presets'
|
||||
|
||||
const nodeTypes = {
|
||||
source: SourceNode,
|
||||
pipe: PipeNode,
|
||||
sink: SinkNode,
|
||||
}
|
||||
|
||||
const edgeTypes = {
|
||||
animatedPipe: AnimatedPipeEdge,
|
||||
}
|
||||
|
||||
export default function FlowCanvas() {
|
||||
const defaultPreset = typeof window !== 'undefined' && window.innerWidth < 768 ? 0 : 0
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(presets[defaultPreset].nodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(presets[defaultPreset].edges)
|
||||
const [isPlaying, setIsPlaying] = useState(true)
|
||||
const [nodeIdCounter, setNodeIdCounter] = useState(100)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
setEdges((eds) =>
|
||||
addEdge(
|
||||
{ ...connection, type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
eds
|
||||
)
|
||||
)
|
||||
},
|
||||
[setEdges]
|
||||
)
|
||||
|
||||
const handleAddNode = useCallback(
|
||||
(type: 'source' | 'pipe' | 'sink') => {
|
||||
const id = `n${nodeIdCounter}`
|
||||
setNodeIdCounter((c) => c + 1)
|
||||
|
||||
const labels = { source: 'Source', pipe: 'Pipe', sink: 'Sink' }
|
||||
const newNode: Node = {
|
||||
id,
|
||||
type,
|
||||
position: { x: 200 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||||
data: {
|
||||
label: labels[type],
|
||||
...(type === 'source' ? { flowRate: 5 } : {}),
|
||||
...(type === 'sink' ? { fillLevel: 0 } : {}),
|
||||
},
|
||||
}
|
||||
setNodes((nds) => [...nds, newNode])
|
||||
},
|
||||
[nodeIdCounter, setNodes]
|
||||
)
|
||||
|
||||
const handleLoadPreset = useCallback(
|
||||
(index: number) => {
|
||||
const preset = presets[index]
|
||||
setNodes(preset.nodes)
|
||||
setEdges(preset.edges)
|
||||
},
|
||||
[setNodes, setEdges]
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setNodes(presets[2].nodes) // Blank preset
|
||||
setEdges(presets[2].edges)
|
||||
}, [setNodes, setEdges])
|
||||
|
||||
const showMiniMap = typeof window !== 'undefined' && window.innerWidth >= 768
|
||||
|
||||
return (
|
||||
<div className="w-full h-[500px] md:h-[600px] rounded-lg border border-zen/10 overflow-hidden relative bg-void">
|
||||
<FlowToolbar
|
||||
onAddNode={handleAddNode}
|
||||
onLoadPreset={handleLoadPreset}
|
||||
onReset={handleReset}
|
||||
isPlaying={isPlaying}
|
||||
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
||||
/>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={{ type: 'animatedPipe' }}
|
||||
fitView
|
||||
className={!isPlaying ? '[&_.animate-pipe-flow]:!animation-play-state-paused' : ''}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#f5f5dc10" />
|
||||
<Controls position="bottom-right" />
|
||||
{showMiniMap && (
|
||||
<MiniMap
|
||||
position="bottom-left"
|
||||
nodeColor="#2dd4bf40"
|
||||
maskColor="#0a0a0acc"
|
||||
/>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { presets } from './presets'
|
||||
|
||||
interface FlowToolbarProps {
|
||||
onAddNode: (type: 'source' | 'pipe' | 'sink') => void
|
||||
onLoadPreset: (index: number) => void
|
||||
onReset: () => void
|
||||
isPlaying: boolean
|
||||
onTogglePlay: () => void
|
||||
}
|
||||
|
||||
export default function FlowToolbar({
|
||||
onAddNode,
|
||||
onLoadPreset,
|
||||
onReset,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
}: FlowToolbarProps) {
|
||||
return (
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-wrap gap-2">
|
||||
{/* Add nodes */}
|
||||
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
|
||||
<button
|
||||
onClick={() => onAddNode('source')}
|
||||
className="px-3 py-1.5 text-xs text-flow-green/70 hover:bg-flow-green/10 rounded transition-colors"
|
||||
title="Add Source"
|
||||
>
|
||||
+ Source
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddNode('pipe')}
|
||||
className="px-3 py-1.5 text-xs text-zen/50 hover:bg-zen/10 rounded transition-colors"
|
||||
title="Add Pipe"
|
||||
>
|
||||
+ Pipe
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddNode('sink')}
|
||||
className="px-3 py-1.5 text-xs text-zen/40 hover:bg-zen/10 rounded transition-colors"
|
||||
title="Add Sink"
|
||||
>
|
||||
+ Sink
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
|
||||
{presets.map((preset, i) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
onClick={() => onLoadPreset(i)}
|
||||
className="px-3 py-1.5 text-xs text-zen/50 hover:bg-zen/10 rounded transition-colors"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex gap-1 bg-nothing/80 backdrop-blur-sm rounded-lg p-1 border border-zen/10">
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="px-3 py-1.5 text-xs text-flow-green/70 hover:bg-flow-green/10 rounded transition-colors"
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-3 py-1.5 text-xs text-anti-green/50 hover:bg-anti-green/10 rounded transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react'
|
||||
|
||||
export default function AnimatedPipeEdge({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
data,
|
||||
style,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
})
|
||||
|
||||
const flowRate = (data as any)?.flowRate ?? 3
|
||||
const pipeWidth = Math.max(2, Math.min(12, flowRate * 2))
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Outer wall */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="#2dd4bf"
|
||||
strokeWidth={pipeWidth + 4}
|
||||
strokeOpacity={0.15}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Inner flow with animation */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="#2dd4bf"
|
||||
strokeWidth={pipeWidth}
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
className="animate-pipe-flow"
|
||||
style={style}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
'use client'
|
||||
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
|
||||
export default function PipeNode({ data }: NodeProps) {
|
||||
return (
|
||||
<div className="flow-node border-zen/20">
|
||||
<Handle type="target" position={Position.Left} className="!bg-zen/40 !w-3 !h-3" />
|
||||
<Handle type="source" position={Position.Right} className="!bg-zen/40 !w-3 !h-3" />
|
||||
<div className="text-center">
|
||||
<div className="text-zen/50 text-lg mb-1">⟷</div>
|
||||
<div className="text-zen/60 text-xs font-mono">{(data as any).label || 'Pipe'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
|
||||
export default function SinkNode({ data }: NodeProps) {
|
||||
const fillLevel = (data as any).fillLevel ?? 0
|
||||
|
||||
return (
|
||||
<div className="flow-node border-flow-green/30">
|
||||
<Handle type="target" position={Position.Left} className="!bg-flow-green/60 !w-3 !h-3" />
|
||||
<div className="text-center">
|
||||
<div className="text-zen/40 text-lg mb-1">◎</div>
|
||||
<div className="text-zen/60 text-xs font-mono">{(data as any).label || 'Sink'}</div>
|
||||
{/* Fill level indicator */}
|
||||
<div className="w-full h-2 bg-void/50 rounded mt-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-flow-green/60 rounded transition-all duration-500"
|
||||
style={{ width: `${Math.min(100, fillLevel)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
'use client'
|
||||
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
|
||||
export default function SourceNode({ data }: NodeProps) {
|
||||
return (
|
||||
<div className="flow-node border-flow-green/40">
|
||||
<Handle type="source" position={Position.Right} className="!bg-flow-green !w-3 !h-3" />
|
||||
<div className="text-center">
|
||||
<div className="text-flow-green text-lg mb-1">⊕</div>
|
||||
<div className="text-zen/70 text-xs font-mono">{(data as any).label || 'Source'}</div>
|
||||
{(data as any).flowRate !== undefined && (
|
||||
<div className="text-flow-green/50 text-[10px] mt-1">
|
||||
{(data as any).flowRate} units/s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
export interface FlowPreset {
|
||||
name: string
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export const extractivePreset: FlowPreset = {
|
||||
name: 'Extractive',
|
||||
nodes: [
|
||||
{ id: 's1', type: 'source', position: { x: 50, y: 50 }, data: { label: 'Labor', flowRate: 5 } },
|
||||
{ id: 's2', type: 'source', position: { x: 50, y: 180 }, data: { label: 'Nature', flowRate: 8 } },
|
||||
{ id: 's3', type: 'source', position: { x: 50, y: 310 }, data: { label: 'Creativity', flowRate: 4 } },
|
||||
{ id: 'p1', type: 'pipe', position: { x: 300, y: 160 }, data: { label: 'Finance' } },
|
||||
{ id: 'k1', type: 'sink', position: { x: 550, y: 80 }, data: { label: 'Shareholders', fillLevel: 85 } },
|
||||
{ id: 'k2', type: 'sink', position: { x: 550, y: 230 }, data: { label: 'Executives', fillLevel: 70 } },
|
||||
{ id: 'k3', type: 'sink', position: { x: 550, y: 370 }, data: { label: 'Public Good', fillLevel: 10 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: 's1', target: 'p1', type: 'animatedPipe', data: { flowRate: 5 } },
|
||||
{ id: 'e2', source: 's2', target: 'p1', type: 'animatedPipe', data: { flowRate: 8 } },
|
||||
{ id: 'e3', source: 's3', target: 'p1', type: 'animatedPipe', data: { flowRate: 4 } },
|
||||
{ id: 'e4', source: 'p1', target: 'k1', type: 'animatedPipe', data: { flowRate: 8 } },
|
||||
{ id: 'e5', source: 'p1', target: 'k2', type: 'animatedPipe', data: { flowRate: 6 } },
|
||||
{ id: 'e6', source: 'p1', target: 'k3', type: 'animatedPipe', data: { flowRate: 1 } },
|
||||
],
|
||||
}
|
||||
|
||||
export const regenerativePreset: FlowPreset = {
|
||||
name: 'Regenerative',
|
||||
nodes: [
|
||||
{ id: 's1', type: 'source', position: { x: 50, y: 100 }, data: { label: 'Commons', flowRate: 6 } },
|
||||
{ id: 's2', type: 'source', position: { x: 50, y: 280 }, data: { label: 'Ecology', flowRate: 5 } },
|
||||
{ id: 'p1', type: 'pipe', position: { x: 280, y: 60 }, data: { label: 'Community' } },
|
||||
{ id: 'p2', type: 'pipe', position: { x: 280, y: 200 }, data: { label: 'Care' } },
|
||||
{ id: 'p3', type: 'pipe', position: { x: 280, y: 340 }, data: { label: 'Knowledge' } },
|
||||
{ id: 'k1', type: 'sink', position: { x: 520, y: 100 }, data: { label: 'Stewardship', fillLevel: 60 } },
|
||||
{ id: 'k2', type: 'sink', position: { x: 520, y: 280 }, data: { label: 'Regeneration', fillLevel: 55 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: 's1', target: 'p1', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e2', source: 's1', target: 'p2', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e3', source: 's2', target: 'p2', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e4', source: 's2', target: 'p3', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e5', source: 'p1', target: 'k1', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e6', source: 'p2', target: 'k1', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e7', source: 'p2', target: 'k2', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
{ id: 'e8', source: 'p3', target: 'k2', type: 'animatedPipe', data: { flowRate: 3 } },
|
||||
],
|
||||
}
|
||||
|
||||
export const blankPreset: FlowPreset = {
|
||||
name: 'Blank',
|
||||
nodes: [
|
||||
{ id: 's1', type: 'source', position: { x: 100, y: 200 }, data: { label: 'Source', flowRate: 5 } },
|
||||
{ id: 'k1', type: 'sink', position: { x: 500, y: 200 }, data: { label: 'Sink', fillLevel: 0 } },
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
export const presets = [regenerativePreset, extractivePreset, blankPreset]
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
'use client'
|
||||
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
|
||||
export default function CTASection() {
|
||||
return (
|
||||
<section className="min-h-[60vh] flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-void" />
|
||||
|
||||
<div className="relative z-10 text-center max-w-3xl">
|
||||
<ScrollReveal>
|
||||
<h2 className="font-marker text-4xl md:text-6xl text-zen/80 mb-4 wobble-4">
|
||||
Ready to <span className="text-flow-green">flow</span>?
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.2}>
|
||||
<p className="font-caveat text-2xl text-zen/40 mb-12">
|
||||
You already are.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.4}>
|
||||
<p className="text-zen/20 text-sm mb-8">
|
||||
NoFi → FlowFi → ???
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.5}>
|
||||
<a
|
||||
href="https://nofi.lol"
|
||||
className="inline-block border border-zen/10 px-8 py-4 text-zen/30 hover:text-flow-green/60 hover:border-flow-green/30 transition-all duration-1000 text-sm"
|
||||
>
|
||||
← back to NoFi
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="relative z-10 w-full border-t border-zen/5 mt-20 pt-6 flex justify-between items-center max-w-4xl">
|
||||
<span className="text-zen/15 text-xs">
|
||||
Part of the CoFi cinematic universe
|
||||
</span>
|
||||
<a
|
||||
href="https://nofi.lol"
|
||||
className="text-zen/15 text-xs hover:text-flow-green/40 transition-all duration-1000"
|
||||
>
|
||||
nofi.lol
|
||||
</a>
|
||||
</footer>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
|
||||
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
|
||||
import SectionHeader from '@/components/ui/SectionHeader'
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
import { extractiveFlows } from '@/lib/sankey-data'
|
||||
|
||||
export default function ExtractivePipeSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void via-less to-void opacity-80" />
|
||||
{/* Red tint overlay */}
|
||||
<div className="absolute inset-0 bg-anti-green/5" />
|
||||
|
||||
<div className="relative z-10 max-w-5xl w-full text-center">
|
||||
<SectionHeader wobble={3}>The Extractive Pattern</SectionHeader>
|
||||
|
||||
<ScrollReveal delay={0.2}>
|
||||
<div className="my-12">
|
||||
<SankeyDiagram
|
||||
data={extractiveFlows}
|
||||
showLabels={true}
|
||||
speed="fast"
|
||||
showParticles={true}
|
||||
showDistortion={false}
|
||||
maxParticles={6}
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.4}>
|
||||
<blockquote className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto border-l-2 border-anti-green/30 pl-6 wobble-1">
|
||||
The plumbing hired a marketing team and <span className="text-anti-green/70">went public</span>.
|
||||
</blockquote>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
'use client'
|
||||
|
||||
export default function HeroSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 overflow-hidden">
|
||||
{/* Background with water ripple SVG filter */}
|
||||
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 0 }}>
|
||||
<defs>
|
||||
<filter id="hero-ripple">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.01 0.02"
|
||||
numOctaves={3}
|
||||
seed={5}
|
||||
result="turbulence"
|
||||
>
|
||||
<animate
|
||||
attributeName="seed"
|
||||
from="0"
|
||||
to="200"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="turbulence"
|
||||
scale={8}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#hero-gradient)"
|
||||
className="water-distortion"
|
||||
filter="url(#hero-ripple)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="hero-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#0a0a0a" />
|
||||
<stop offset="40%" stopColor="#1a1a2e" />
|
||||
<stop offset="70%" stopColor="#16213e" />
|
||||
<stop offset="100%" stopColor="#0a0a0a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing/50 to-void opacity-90" />
|
||||
|
||||
<div className="relative z-10 text-center max-w-4xl">
|
||||
<p className="text-sloth/50 text-sm tracking-[0.3em] uppercase mb-6 animate-slow-pulse">
|
||||
everything flows
|
||||
</p>
|
||||
|
||||
<h1 className="font-marker text-6xl md:text-8xl lg:text-9xl mb-6 wobble-1">
|
||||
<span className="text-flow-green">Flow</span>
|
||||
<span className="text-zen/90">Fi</span>
|
||||
<span className="text-zen/20">.</span>
|
||||
</h1>
|
||||
|
||||
<p className="font-caveat text-2xl md:text-3xl text-zen/50 mb-12">
|
||||
Everything flows. The question is: <span className="text-flow-green/80">where?</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://nofi.lol"
|
||||
className="inline-block border border-zen/10 px-6 py-3 text-sm text-zen/30 hover:text-flow-green/60 hover:border-flow-green/30 transition-all duration-1000"
|
||||
>
|
||||
← back to NoFi
|
||||
</a>
|
||||
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-drift">
|
||||
<span className="text-zen/15 text-sm">scroll to flow ↓</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
|
||||
import SectionHeader from '@/components/ui/SectionHeader'
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
import { naturalFlows } from '@/lib/sankey-data'
|
||||
|
||||
export default function NatureFlowsSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing to-void opacity-80" />
|
||||
|
||||
<div className="relative z-10 max-w-5xl w-full text-center">
|
||||
<SectionHeader wobble={2}>Natural Flows</SectionHeader>
|
||||
|
||||
<ScrollReveal delay={0.2}>
|
||||
<div className="my-12">
|
||||
<SankeyDiagram
|
||||
data={naturalFlows}
|
||||
showLabels={false}
|
||||
speed="slow"
|
||||
showParticles={true}
|
||||
showDistortion={true}
|
||||
maxParticles={8}
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.4}>
|
||||
<p className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto">
|
||||
In living systems, value doesn't accumulate. It <span className="text-flow-green/70">circulates</span>.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
'use client'
|
||||
|
||||
import SankeyDiagram from '@/components/visualizations/SankeyDiagram'
|
||||
import SectionHeader from '@/components/ui/SectionHeader'
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
import { regenerativeFlows } from '@/lib/sankey-data'
|
||||
|
||||
export default function RegenerativeSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void via-nothing to-void opacity-80" />
|
||||
{/* Green tint overlay */}
|
||||
<div className="absolute inset-0 bg-flow-green/3" />
|
||||
|
||||
<div className="relative z-10 max-w-5xl w-full text-center">
|
||||
<SectionHeader wobble={1}>The Regenerative Alternative</SectionHeader>
|
||||
|
||||
<ScrollReveal delay={0.2}>
|
||||
<div className="my-12">
|
||||
<SankeyDiagram
|
||||
data={regenerativeFlows}
|
||||
showLabels={true}
|
||||
speed="normal"
|
||||
showParticles={true}
|
||||
showDistortion={true}
|
||||
maxParticles={8}
|
||||
/>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.4}>
|
||||
<p className="font-caveat text-xl md:text-2xl text-zen/50 max-w-2xl mx-auto">
|
||||
<span className="text-flow-green/70">Pre-distributive</span>. <span className="text-flow-green/70">Re-distributive</span>. Naturally.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import SectionHeader from '@/components/ui/SectionHeader'
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
|
||||
const FlowCanvas = dynamic(() => import('@/components/sandbox/FlowCanvas'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="w-full h-[500px] md:h-[600px] rounded-lg border border-zen/10 bg-void flex items-center justify-center">
|
||||
<span className="text-zen/20 text-sm animate-slow-pulse">loading canvas...</span>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
|
||||
export default function SandboxSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-void" />
|
||||
|
||||
<div className="relative z-10 max-w-6xl w-full">
|
||||
<SectionHeader wobble={2}>Flow Sandbox</SectionHeader>
|
||||
|
||||
<ScrollReveal delay={0.1}>
|
||||
<p className="font-caveat text-lg md:text-xl text-zen/40 text-center mb-8 max-w-2xl mx-auto">
|
||||
Reconnect the flows. What would you build?
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.2}>
|
||||
<FlowCanvas />
|
||||
</ScrollReveal>
|
||||
|
||||
<ScrollReveal delay={0.3}>
|
||||
<p className="text-zen/20 text-xs text-center mt-4">
|
||||
drag nodes • connect ports • load presets • build your own flow system
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
'use client'
|
||||
|
||||
import ScrollReveal from '@/components/ui/ScrollReveal'
|
||||
|
||||
const lines = [
|
||||
'NoFi was the diagnosis.',
|
||||
'FlowFi is the prognosis.',
|
||||
'Not no flows — better flows.',
|
||||
'Not no finance — living finance.',
|
||||
'Finance as metabolism, not mechanism.',
|
||||
]
|
||||
|
||||
export default function TransitionSection() {
|
||||
return (
|
||||
<section className="min-h-screen flex flex-col items-center justify-center relative px-4 py-20">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-void via-less to-void opacity-80" />
|
||||
|
||||
<div className="relative z-10 max-w-3xl w-full space-y-12 text-center">
|
||||
{lines.map((line, i) => (
|
||||
<ScrollReveal key={i} delay={i * 0.15}>
|
||||
<p className={`font-caveat text-2xl md:text-4xl text-zen/60 wobble-${(i % 4) + 1}`}>
|
||||
{line}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
|
||||
interface ScrollRevealProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export default function ScrollReveal({ children, className = '', delay = 0 }: ScrollRevealProps) {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
|
||||
transition={{ duration: 0.8, delay, ease: 'easeOut' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import ScrollReveal from './ScrollReveal'
|
||||
|
||||
interface SectionHeaderProps {
|
||||
children: React.ReactNode
|
||||
wobble?: 1 | 2 | 3 | 4
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SectionHeader({ children, wobble = 1, className = '' }: SectionHeaderProps) {
|
||||
return (
|
||||
<ScrollReveal>
|
||||
<h2
|
||||
className={`font-marker text-3xl md:text-5xl text-zen/90 mb-8 wobble-${wobble} ${className}`}
|
||||
>
|
||||
<span className="scrawl-underline">{children}</span>
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
'use client'
|
||||
|
||||
interface AnimatedPipeProps {
|
||||
d: string
|
||||
width?: number
|
||||
color?: string
|
||||
speed?: 'slow' | 'normal' | 'fast'
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export default function AnimatedPipe({
|
||||
d,
|
||||
width = 4,
|
||||
color = '#2dd4bf',
|
||||
speed = 'normal',
|
||||
opacity = 0.6,
|
||||
}: AnimatedPipeProps) {
|
||||
const speedClass = {
|
||||
slow: 'animate-pipe-flow-slow',
|
||||
normal: 'animate-pipe-flow',
|
||||
fast: 'animate-pipe-flow-fast',
|
||||
}[speed]
|
||||
|
||||
return (
|
||||
<g>
|
||||
{/* Outer wall */}
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={width + 4}
|
||||
strokeOpacity={opacity * 0.3}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Inner flow */}
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={width}
|
||||
strokeOpacity={opacity}
|
||||
strokeLinecap="round"
|
||||
className={speedClass}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
'use client'
|
||||
|
||||
interface FlowParticlesProps {
|
||||
pathId: string
|
||||
count?: number
|
||||
color?: string
|
||||
size?: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export default function FlowParticles({
|
||||
pathId,
|
||||
count = 3,
|
||||
color = '#2dd4bf',
|
||||
size = 3,
|
||||
duration = 3,
|
||||
}: FlowParticlesProps) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
r={size}
|
||||
fill={color}
|
||||
className="animate-particle"
|
||||
style={{
|
||||
offsetPath: `url(#${pathId})`,
|
||||
'--duration': `${duration}s`,
|
||||
'--delay': `${(i / count) * duration}s`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
sankey as d3Sankey,
|
||||
sankeyLinkHorizontal,
|
||||
SankeyNode,
|
||||
SankeyLink,
|
||||
} from 'd3-sankey'
|
||||
import { FlowData } from '@/lib/types'
|
||||
import AnimatedPipe from './AnimatedPipe'
|
||||
import FlowParticles from './FlowParticles'
|
||||
import WaterDistortion from './WaterDistortion'
|
||||
|
||||
interface SankeyDiagramProps {
|
||||
data: FlowData
|
||||
width?: number
|
||||
height?: number
|
||||
showLabels?: boolean
|
||||
speed?: 'slow' | 'normal' | 'fast'
|
||||
showParticles?: boolean
|
||||
showDistortion?: boolean
|
||||
maxParticles?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SNode {
|
||||
id: string
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
interface SLink {
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export default function SankeyDiagram({
|
||||
data,
|
||||
width = 800,
|
||||
height = 400,
|
||||
showLabels = false,
|
||||
speed = 'normal',
|
||||
showParticles = true,
|
||||
showDistortion = false,
|
||||
maxParticles = 6,
|
||||
className = '',
|
||||
}: SankeyDiagramProps) {
|
||||
const { nodes, links } = useMemo(() => {
|
||||
const sankeyNodes = data.nodes.map(n => ({ ...n }))
|
||||
const sankeyLinks = data.links.map(l => ({
|
||||
source: l.source,
|
||||
target: l.target,
|
||||
value: l.value,
|
||||
color: l.color,
|
||||
}))
|
||||
|
||||
const generator = (d3Sankey as any)()
|
||||
.nodeId((d: any) => d.id)
|
||||
.nodeWidth(12)
|
||||
.nodePadding(16)
|
||||
.extent([[1, 1], [width - 1, height - 1]])
|
||||
|
||||
const result = generator({
|
||||
nodes: sankeyNodes,
|
||||
links: sankeyLinks,
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: result.nodes as any[],
|
||||
links: result.links as any[],
|
||||
}
|
||||
}, [data, width, height])
|
||||
|
||||
const linkPath = sankeyLinkHorizontal()
|
||||
const filterId = `distortion-${Math.random().toString(36).slice(2, 7)}`
|
||||
|
||||
let particleCount = 0
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className={`w-full h-auto ${className}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
{showDistortion && <WaterDistortion id={filterId} />}
|
||||
{/* Define paths for particles */}
|
||||
{links.map((link, i) => {
|
||||
const d = linkPath(link as any)
|
||||
return d ? (
|
||||
<path key={`path-${i}`} id={`flow-path-${i}`} d={d} fill="none" />
|
||||
) : null
|
||||
})}
|
||||
</defs>
|
||||
|
||||
<g
|
||||
className={showDistortion ? 'water-distortion' : ''}
|
||||
filter={showDistortion ? `url(#${filterId})` : undefined}
|
||||
>
|
||||
{/* Links */}
|
||||
{links.map((link, i) => {
|
||||
const d = linkPath(link as any)
|
||||
if (!d) return null
|
||||
const linkWidth = Math.max(2, (link as any).width || 4)
|
||||
return (
|
||||
<AnimatedPipe
|
||||
key={`link-${i}`}
|
||||
d={d}
|
||||
width={linkWidth}
|
||||
color={link.color || '#2dd4bf'}
|
||||
speed={speed}
|
||||
opacity={0.6}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Particles */}
|
||||
{showParticles && links.map((link, i) => {
|
||||
if (particleCount >= maxParticles) return null
|
||||
const pCount = Math.min(2, maxParticles - particleCount)
|
||||
particleCount += pCount
|
||||
return (
|
||||
<FlowParticles
|
||||
key={`particles-${i}`}
|
||||
pathId={`flow-path-${i}`}
|
||||
count={pCount}
|
||||
color={link.color?.replace(/[0-9a-f]{2}$/i, 'ff') || '#2dd4bf'}
|
||||
size={2}
|
||||
duration={speed === 'fast' ? 2 : speed === 'slow' ? 5 : 3}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Nodes */}
|
||||
{nodes.map((node, i) => (
|
||||
<g key={`node-${i}`}>
|
||||
<rect
|
||||
x={node.x0}
|
||||
y={node.y0}
|
||||
width={(node.x1 ?? 0) - (node.x0 ?? 0)}
|
||||
height={(node.y1 ?? 0) - (node.y0 ?? 0)}
|
||||
fill={node.color || '#2dd4bf'}
|
||||
rx={3}
|
||||
opacity={0.8}
|
||||
/>
|
||||
{showLabels && node.label && (
|
||||
<text
|
||||
x={(node.x0 ?? 0) < width / 2
|
||||
? (node.x1 ?? 0) + 8
|
||||
: (node.x0 ?? 0) - 8
|
||||
}
|
||||
y={((node.y0 ?? 0) + (node.y1 ?? 0)) / 2}
|
||||
textAnchor={(node.x0 ?? 0) < width / 2 ? 'start' : 'end'}
|
||||
dominantBaseline="middle"
|
||||
fill="#f5f5dc"
|
||||
fontSize={12}
|
||||
fontFamily="'Courier New', monospace"
|
||||
opacity={0.7}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
interface WaterDistortionProps {
|
||||
id?: string
|
||||
intensity?: number
|
||||
}
|
||||
|
||||
export default function WaterDistortion({
|
||||
id = 'water-distortion',
|
||||
intensity = 0.003,
|
||||
}: WaterDistortionProps) {
|
||||
return (
|
||||
<filter id={id}>
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency={`${intensity} ${intensity * 1.5}`}
|
||||
numOctaves={3}
|
||||
seed={42}
|
||||
result="turbulence"
|
||||
>
|
||||
<animate
|
||||
attributeName="seed"
|
||||
from="0"
|
||||
to="100"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</feTurbulence>
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="turbulence"
|
||||
scale={4}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
services:
|
||||
flowfi:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.flowfi.rule=Host(`flowfi.network`) || Host(`www.flowfi.network`)"
|
||||
- "traefik.http.services.flowfi.loadbalancer.server.port=80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
networks:
|
||||
- traefik-public
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
- CHOWN
|
||||
- SETGID
|
||||
- SETUID
|
||||
- DAC_OVERRIDE
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/cache/nginx
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { FlowData } from './types'
|
||||
|
||||
// S2: Natural flows — abstract organic Sankey (unlabeled, acyclic)
|
||||
// Represents sunlight/rain → photosynthesis/absorption → distribution → endpoints
|
||||
export const naturalFlows: FlowData = {
|
||||
nodes: [
|
||||
{ id: 'sun', color: '#fbbf24' },
|
||||
{ id: 'rain', color: '#60a5fa' },
|
||||
{ id: 'photo', color: '#34d399' },
|
||||
{ id: 'soil', color: '#c3b091' },
|
||||
{ id: 'canopy', color: '#34d399' },
|
||||
{ id: 'roots', color: '#a78bfa' },
|
||||
{ id: 'stream', color: '#60a5fa' },
|
||||
{ id: 'mycelium', color: '#a78bfa' },
|
||||
{ id: 'fauna', color: '#f59e0b' },
|
||||
{ id: 'atmosphere', color: '#94a3b8' },
|
||||
{ id: 'humus', color: '#92704f' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'sun', target: 'photo', value: 8, color: '#fbbf2480' },
|
||||
{ source: 'sun', target: 'canopy', value: 4, color: '#fbbf2440' },
|
||||
{ source: 'rain', target: 'stream', value: 5, color: '#60a5fa80' },
|
||||
{ source: 'rain', target: 'soil', value: 6, color: '#60a5fa60' },
|
||||
{ source: 'photo', target: 'canopy', value: 5, color: '#34d39980' },
|
||||
{ source: 'photo', target: 'roots', value: 3, color: '#34d39960' },
|
||||
{ source: 'soil', target: 'roots', value: 4, color: '#c3b09180' },
|
||||
{ source: 'soil', target: 'mycelium', value: 3, color: '#c3b09160' },
|
||||
{ source: 'roots', target: 'mycelium', value: 3, color: '#a78bfa80' },
|
||||
{ source: 'canopy', target: 'atmosphere', value: 4, color: '#34d39960' },
|
||||
{ source: 'canopy', target: 'fauna', value: 3, color: '#34d39940' },
|
||||
{ source: 'mycelium', target: 'humus', value: 4, color: '#a78bfa60' },
|
||||
{ source: 'stream', target: 'atmosphere', value: 3, color: '#60a5fa60' },
|
||||
{ source: 'fauna', target: 'humus', value: 2, color: '#f59e0b60' },
|
||||
{ source: 'roots', target: 'humus', value: 2, color: '#a78bfa40' },
|
||||
],
|
||||
}
|
||||
|
||||
// S3: Extractive pattern — finance funnel (labeled, acyclic)
|
||||
export const extractiveFlows: FlowData = {
|
||||
nodes: [
|
||||
{ id: 'labor', label: 'Labor', color: '#e9456060' },
|
||||
{ id: 'creativity', label: 'Creativity', color: '#e9456060' },
|
||||
{ id: 'nature', label: 'Nature', color: '#e9456060' },
|
||||
{ id: 'communities', label: 'Communities', color: '#e9456060' },
|
||||
{ id: 'finance', label: 'Finance', color: '#e94560' },
|
||||
{ id: 'shareholders', label: 'Shareholders', color: '#e94560cc' },
|
||||
{ id: 'executives', label: 'Executives', color: '#e94560cc' },
|
||||
{ id: 'tax-havens', label: 'Tax Havens', color: '#e94560aa' },
|
||||
{ id: 'public-good', label: 'Public Good', color: '#e9456030' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'labor', target: 'finance', value: 10, color: '#e9456040' },
|
||||
{ source: 'creativity', target: 'finance', value: 8, color: '#e9456040' },
|
||||
{ source: 'nature', target: 'finance', value: 12, color: '#e9456040' },
|
||||
{ source: 'communities', target: 'finance', value: 6, color: '#e9456040' },
|
||||
{ source: 'finance', target: 'shareholders', value: 15, color: '#e9456080' },
|
||||
{ source: 'finance', target: 'executives', value: 12, color: '#e9456080' },
|
||||
{ source: 'finance', target: 'tax-havens', value: 7, color: '#e9456060' },
|
||||
{ source: 'finance', target: 'public-good', value: 2, color: '#e9456020' },
|
||||
],
|
||||
}
|
||||
|
||||
// S4: Regenerative alternative — redistributive mesh (labeled, acyclic)
|
||||
// Uses separate input/output nodes to represent the circular nature without actual cycles
|
||||
export const regenerativeFlows: FlowData = {
|
||||
nodes: [
|
||||
{ id: 'commons-in', label: 'Commons', color: '#2dd4bf' },
|
||||
{ id: 'labor-r', label: 'Labor', color: '#2dd4bfcc' },
|
||||
{ id: 'ecology', label: 'Ecology', color: '#34d399' },
|
||||
{ id: 'care', label: 'Care', color: '#a78bfa' },
|
||||
{ id: 'creativity-r', label: 'Creativity', color: '#60a5fa' },
|
||||
{ id: 'community', label: 'Community', color: '#fbbf24' },
|
||||
{ id: 'knowledge', label: 'Knowledge', color: '#f472b6' },
|
||||
{ id: 'stewardship', label: 'Stewardship', color: '#34d399cc' },
|
||||
{ id: 'commons-out', label: 'Commons', color: '#2dd4bf' },
|
||||
],
|
||||
links: [
|
||||
{ source: 'commons-in', target: 'labor-r', value: 5, color: '#2dd4bf60' },
|
||||
{ source: 'commons-in', target: 'ecology', value: 6, color: '#2dd4bf60' },
|
||||
{ source: 'commons-in', target: 'care', value: 5, color: '#2dd4bf60' },
|
||||
{ source: 'commons-in', target: 'creativity-r', value: 4, color: '#2dd4bf60' },
|
||||
{ source: 'labor-r', target: 'community', value: 4, color: '#2dd4bf40' },
|
||||
{ source: 'labor-r', target: 'knowledge', value: 3, color: '#2dd4bf40' },
|
||||
{ source: 'ecology', target: 'stewardship', value: 4, color: '#34d39960' },
|
||||
{ source: 'ecology', target: 'community', value: 3, color: '#34d39940' },
|
||||
{ source: 'care', target: 'community', value: 4, color: '#a78bfa60' },
|
||||
{ source: 'care', target: 'knowledge', value: 3, color: '#a78bfa40' },
|
||||
{ source: 'creativity-r', target: 'knowledge', value: 3, color: '#60a5fa60' },
|
||||
{ source: 'creativity-r', target: 'community', value: 3, color: '#60a5fa40' },
|
||||
{ source: 'community', target: 'commons-out', value: 6, color: '#fbbf2460' },
|
||||
{ source: 'knowledge', target: 'commons-out', value: 4, color: '#f472b660' },
|
||||
{ source: 'stewardship', target: 'commons-out', value: 4, color: '#34d39960' },
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export interface FlowNode {
|
||||
id: string
|
||||
label?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface FlowLink {
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface FlowData {
|
||||
nodes: FlowNode[]
|
||||
links: FlowLink[]
|
||||
}
|
||||
|
||||
export interface SandboxNode {
|
||||
id: string
|
||||
type: 'source' | 'pipe' | 'sink'
|
||||
label: string
|
||||
flowRate?: number
|
||||
fillLevel?: number
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
distDir: 'out',
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "flowfi-network",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"next": "^16.1.6",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"d3-shape": "^3.2.0",
|
||||
"@xyflow/react": "^12.6.0",
|
||||
"framer-motion": "^12.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/d3-sankey": "^0.12.5",
|
||||
"@types/d3-shape": "^3.1.7",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue