feat: initial rfunds.online with TBFF interactive platform
Landing page explaining threshold-based flow funding, interactive demo at /tbff with the full Treasury → sub-funnels → outcomes preset, and user-created spaces at /space with save/load/share functionality via localStorage and URL hash state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6b3f78d756
commit
1f40ab57cf
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Dependencies stage
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
RUN pnpm install --frozen-lockfile || pnpm install
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
@ -1,35 +1,39 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next'
|
||||||
import localFont from "next/font/local";
|
import localFont from 'next/font/local'
|
||||||
import "./globals.css";
|
import './globals.css'
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
src: "./fonts/GeistVF.woff",
|
src: './fonts/GeistVF.woff',
|
||||||
variable: "--font-geist-sans",
|
variable: '--font-geist-sans',
|
||||||
weight: "100 900",
|
weight: '100 900',
|
||||||
});
|
})
|
||||||
const geistMono = localFont({
|
const geistMono = localFont({
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
src: './fonts/GeistMonoVF.woff',
|
||||||
variable: "--font-geist-mono",
|
variable: '--font-geist-mono',
|
||||||
weight: "100 900",
|
weight: '100 900',
|
||||||
});
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'rFunds - Threshold-Based Flow Funding',
|
||||||
description: "Generated by create next app",
|
description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms. Create interconnected funding funnels with overflow routing and outcome tracking.',
|
||||||
};
|
openGraph: {
|
||||||
|
title: 'rFunds - Threshold-Based Flow Funding',
|
||||||
|
description: 'Design, simulate, and share continuous funding flows with threshold-based mechanisms.',
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://rfunds.online',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
260
app/page.tsx
260
app/page.tsx
|
|
@ -1,101 +1,175 @@
|
||||||
import Image from "next/image";
|
import Link from 'next/link'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
||||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
{/* Nav */}
|
||||||
<Image
|
<nav className="border-b border-slate-700/50 backdrop-blur-sm">
|
||||||
className="dark:invert"
|
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
src="https://nextjs.org/icons/next.svg"
|
<div className="flex items-center gap-2">
|
||||||
alt="Next.js logo"
|
<div className="w-8 h-8 bg-gradient-to-br from-amber-400 to-emerald-500 rounded-lg flex items-center justify-center font-bold text-slate-900 text-sm">
|
||||||
width={180}
|
rF
|
||||||
height={38}
|
</div>
|
||||||
priority
|
<span className="font-semibold text-lg">rFunds</span>
|
||||||
/>
|
</div>
|
||||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
<div className="flex items-center gap-4">
|
||||||
<li className="mb-2">
|
<Link
|
||||||
Get started by editing{" "}
|
href="/tbff"
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
className="text-sm text-slate-300 hover:text-white transition-colors"
|
||||||
app/page.tsx
|
>
|
||||||
</code>
|
Demo
|
||||||
.
|
</Link>
|
||||||
</li>
|
<Link
|
||||||
<li>Save and see your changes instantly.</li>
|
href="/space"
|
||||||
</ol>
|
className="text-sm px-4 py-2 bg-emerald-600 hover:bg-emerald-500 rounded-lg transition-colors font-medium"
|
||||||
|
>
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
Create Space
|
||||||
<a
|
</Link>
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
</div>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="https://nextjs.org/icons/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</nav>
|
||||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
|
||||||
<a
|
{/* Hero */}
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<section className="max-w-6xl mx-auto px-6 pt-20 pb-16">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
target="_blank"
|
<h1 className="text-5xl font-bold mb-6 bg-gradient-to-r from-amber-300 via-emerald-300 to-blue-300 bg-clip-text text-transparent">
|
||||||
rel="noopener noreferrer"
|
Threshold-Based Flow Funding
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-slate-300 mb-8 leading-relaxed">
|
||||||
|
Design continuous funding flows that respond dynamically to threshold conditions.
|
||||||
|
Connect funnels, set overflow rules, and track outcomes in real-time.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/tbff"
|
||||||
|
className="px-6 py-3 bg-slate-700 hover:bg-slate-600 rounded-xl text-lg font-medium transition-all border border-slate-600"
|
||||||
|
>
|
||||||
|
Try the Demo
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/space"
|
||||||
|
className="px-6 py-3 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
||||||
|
>
|
||||||
|
Create Your Own
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How it Works */}
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-16">
|
||||||
|
<h2 className="text-3xl font-bold text-center mb-12">How It Works</h2>
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
{/* Funnel Card */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
|
||||||
|
<div className="w-12 h-12 bg-amber-500/20 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-6 h-6 text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Create Funnels</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Each funnel represents a funding pool with three zones: overflow (above max),
|
||||||
|
healthy (between thresholds), and critical (below min). Set thresholds by
|
||||||
|
dragging handles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flow Card */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
|
||||||
|
<div className="w-12 h-12 bg-emerald-500/20 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-6 h-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Connect Flows</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Connect funnels with overflow edges (sideways) so excess funds automatically
|
||||||
|
route to other pools. Add spending edges (downward) to fund outcomes and
|
||||||
|
deliverables.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulate Card */}
|
||||||
|
<div className="bg-slate-800/50 rounded-2xl p-6 border border-slate-700/50">
|
||||||
|
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<svg className="w-6 h-6 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Simulate & Share</h3>
|
||||||
|
<p className="text-slate-400 text-sm leading-relaxed">
|
||||||
|
Run simulations to see how funds flow through your system. Watch edge widths
|
||||||
|
scale proportionally. Save your space locally and share it with a link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Funnel Diagram */}
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-16">
|
||||||
|
<div className="bg-slate-800/30 rounded-2xl border border-slate-700/50 p-8">
|
||||||
|
<h2 className="text-2xl font-bold mb-8 text-center">The Funnel Metaphor</h2>
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
{/* ASCII-art style funnel */}
|
||||||
|
<div className="font-mono text-sm space-y-0 text-center">
|
||||||
|
<div className="text-amber-400">
|
||||||
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-t-xl px-8 py-3">
|
||||||
|
<div className="font-semibold">OVERFLOW ZONE</div>
|
||||||
|
<div className="text-xs text-amber-300">Above MAX threshold</div>
|
||||||
|
<div className="text-xs text-amber-300/70">Excess funds redistribute to other funnels</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-500 font-bold border-b-2 border-dashed border-amber-500/50 py-1 bg-amber-500/5">
|
||||||
|
-- MAX THRESHOLD --
|
||||||
|
</div>
|
||||||
|
<div className="text-emerald-400">
|
||||||
|
<div className="bg-emerald-500/10 border-x border-emerald-500/30 px-8 py-4">
|
||||||
|
<div className="font-semibold">HEALTHY ZONE</div>
|
||||||
|
<div className="text-xs text-emerald-300">Normal operations</div>
|
||||||
|
<div className="text-xs text-emerald-300/70">Full flow rate, balanced funding</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-red-500 font-bold border-b-2 border-dashed border-red-500/50 py-1 bg-red-500/5">
|
||||||
|
-- MIN THRESHOLD --
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-400">
|
||||||
|
<div className="bg-slate-700/30 border border-slate-600/30 rounded-b-xl px-8 py-3 clip-funnel">
|
||||||
|
<div className="font-semibold text-red-400">CRITICAL ZONE</div>
|
||||||
|
<div className="text-xs text-red-300">Below MIN threshold</div>
|
||||||
|
<div className="text-xs text-slate-500">Outflow restricted, conservation mode</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="max-w-6xl mx-auto px-6 py-16 text-center">
|
||||||
|
<h2 className="text-3xl font-bold mb-4">Ready to design your funding flows?</h2>
|
||||||
|
<p className="text-slate-400 mb-8 max-w-lg mx-auto">
|
||||||
|
Start with the interactive demo or create your own space with custom funnels and connections.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/space"
|
||||||
|
className="inline-block px-8 py-4 bg-emerald-600 hover:bg-emerald-500 rounded-xl text-lg font-medium transition-all shadow-lg shadow-emerald-900/30"
|
||||||
>
|
>
|
||||||
<Image
|
Create Your Space
|
||||||
aria-hidden
|
</Link>
|
||||||
src="https://nextjs.org/icons/file.svg"
|
</section>
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
{/* Footer */}
|
||||||
height={16}
|
<footer className="border-t border-slate-700/50 py-8">
|
||||||
/>
|
<div className="max-w-6xl mx-auto px-6 flex items-center justify-between text-sm text-slate-500">
|
||||||
Learn
|
<span>rFunds.online</span>
|
||||||
</a>
|
<div className="flex items-center gap-6">
|
||||||
<a
|
<Link href="/tbff" className="hover:text-slate-300 transition-colors">Demo</Link>
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<Link href="/space" className="hover:text-slate-300 transition-colors">Create Space</Link>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</div>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { starterNodes } from '@/lib/presets'
|
||||||
|
import { serializeState, deserializeState, saveToLocal, loadFromLocal, listSavedSpaces, deleteFromLocal } from '@/lib/state'
|
||||||
|
import type { FlowNode, SpaceConfig } from '@/lib/types'
|
||||||
|
|
||||||
|
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-slate-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-slate-600">Loading flow editor...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function SpacePage() {
|
||||||
|
const [currentNodes, setCurrentNodes] = useState<FlowNode[]>(starterNodes)
|
||||||
|
const [spaceName, setSpaceName] = useState('')
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false)
|
||||||
|
const [showLoadDialog, setShowLoadDialog] = useState(false)
|
||||||
|
const [savedSpaces, setSavedSpaces] = useState<SpaceConfig[]>([])
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const nodesRef = useRef<FlowNode[]>(starterNodes)
|
||||||
|
|
||||||
|
// Load from URL hash on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const hash = window.location.hash.slice(1)
|
||||||
|
if (hash.startsWith('s=')) {
|
||||||
|
const compressed = hash.slice(2)
|
||||||
|
const state = deserializeState(compressed)
|
||||||
|
if (state) {
|
||||||
|
setCurrentNodes(state.nodes)
|
||||||
|
nodesRef.current = state.nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoaded(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNodesChange = useCallback((nodes: FlowNode[]) => {
|
||||||
|
nodesRef.current = nodes
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleShare = useCallback(() => {
|
||||||
|
const compressed = serializeState(nodesRef.current)
|
||||||
|
const url = `${window.location.origin}/space#s=${compressed}`
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!spaceName.trim()) return
|
||||||
|
saveToLocal(spaceName.trim(), nodesRef.current)
|
||||||
|
setShowSaveDialog(false)
|
||||||
|
setSpaceName('')
|
||||||
|
}, [spaceName])
|
||||||
|
|
||||||
|
const handleLoadOpen = useCallback(() => {
|
||||||
|
setSavedSpaces(listSavedSpaces())
|
||||||
|
setShowLoadDialog(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLoadSpace = useCallback((config: SpaceConfig) => {
|
||||||
|
setCurrentNodes(config.nodes)
|
||||||
|
nodesRef.current = config.nodes
|
||||||
|
setShowLoadDialog(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDeleteSpace = useCallback((name: string) => {
|
||||||
|
deleteFromLocal(name)
|
||||||
|
setSavedSpaces(listSavedSpaces())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
if (confirm('Reset canvas to a single empty funnel? This cannot be undone.')) {
|
||||||
|
setCurrentNodes([...starterNodes])
|
||||||
|
nodesRef.current = [...starterNodes]
|
||||||
|
// Clear URL hash
|
||||||
|
window.history.replaceState(null, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex items-center justify-center bg-slate-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-slate-600">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-screen flex flex-col">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
|
||||||
|
rF
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">rFunds</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-slate-300">Your Space</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadOpen}
|
||||||
|
className="px-3 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Share'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-3 py-1 bg-slate-700 hover:bg-red-600 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<FlowCanvas
|
||||||
|
key={JSON.stringify(currentNodes.map(n => n.id))}
|
||||||
|
initialNodes={currentNodes}
|
||||||
|
mode="space"
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Dialog */}
|
||||||
|
{showSaveDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowSaveDialog(false)}>
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-80" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 mb-4">Save Space</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={spaceName}
|
||||||
|
onChange={e => setSpaceName(e.target.value)}
|
||||||
|
placeholder="Space name..."
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mb-4 text-slate-800"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!spaceName.trim()}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSaveDialog(false)}
|
||||||
|
className="px-4 py-2 text-slate-600 hover:text-slate-800 text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load Dialog */}
|
||||||
|
{showLoadDialog && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowLoadDialog(false)}>
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-96 max-h-[60vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800 mb-4">Load Space</h3>
|
||||||
|
{savedSpaces.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 py-4 text-center">No saved spaces yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{savedSpaces.map((space) => (
|
||||||
|
<div
|
||||||
|
key={space.name}
|
||||||
|
className="flex items-center justify-between p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition-colors"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLoadSpace(space)}
|
||||||
|
className="flex-1 text-left"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium text-slate-800">{space.name}</div>
|
||||||
|
<div className="text-[10px] text-slate-500">
|
||||||
|
{space.nodes.filter(n => n.type === 'funnel').length} funnels •{' '}
|
||||||
|
{space.nodes.filter(n => n.type === 'outcome').length} outcomes •{' '}
|
||||||
|
{new Date(space.updatedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSpace(space.name)}
|
||||||
|
className="text-slate-400 hover:text-red-500 p-1 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLoadDialog(false)}
|
||||||
|
className="w-full mt-4 px-4 py-2 text-slate-600 hover:text-slate-800 text-sm border border-slate-200 rounded-lg"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { demoNodes } from '@/lib/presets'
|
||||||
|
|
||||||
|
const FlowCanvas = dynamic(() => import('@/components/FlowCanvas'), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-full flex items-center justify-center bg-slate-50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-slate-600">Loading flow editor...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function TbffDemo() {
|
||||||
|
return (
|
||||||
|
<main className="h-screen w-screen flex flex-col">
|
||||||
|
{/* Demo Banner */}
|
||||||
|
<div className="bg-slate-800 text-white px-4 py-2 flex items-center justify-between text-sm flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
|
||||||
|
<div className="w-6 h-6 bg-gradient-to-br from-amber-400 to-emerald-500 rounded flex items-center justify-center font-bold text-slate-900 text-[10px]">
|
||||||
|
rF
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">rFunds</span>
|
||||||
|
</Link>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-slate-300">Interactive Demo</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/space"
|
||||||
|
className="px-3 py-1 bg-emerald-600 hover:bg-emerald-500 rounded text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Create Your Own
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<FlowCanvas initialNodes={demoNodes} mode="demo" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,532 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useState, useEffect, useMemo, useRef } from 'react'
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Controls,
|
||||||
|
Background,
|
||||||
|
BackgroundVariant,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
Connection,
|
||||||
|
MarkerType,
|
||||||
|
Panel,
|
||||||
|
useReactFlow,
|
||||||
|
ReactFlowProvider,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
import FunnelNode from './nodes/FunnelNode'
|
||||||
|
import OutcomeNode from './nodes/OutcomeNode'
|
||||||
|
import type { FlowNode, FlowEdge, FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||||
|
import { SPENDING_COLORS, OVERFLOW_COLORS } from '@/lib/presets'
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
funnel: FunnelNode,
|
||||||
|
outcome: OutcomeNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate edges with proportional Sankey-style widths
|
||||||
|
function generateEdges(nodes: FlowNode[]): FlowEdge[] {
|
||||||
|
const edges: FlowEdge[] = []
|
||||||
|
|
||||||
|
const flowValues: number[] = []
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'funnel') return
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
const rate = data.inflowRate || 1
|
||||||
|
|
||||||
|
data.overflowAllocations?.forEach((alloc) => {
|
||||||
|
flowValues.push((alloc.percentage / 100) * rate)
|
||||||
|
})
|
||||||
|
data.spendingAllocations?.forEach((alloc) => {
|
||||||
|
flowValues.push((alloc.percentage / 100) * rate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxFlow = Math.max(...flowValues, 1)
|
||||||
|
const MIN_WIDTH = 3
|
||||||
|
const MAX_WIDTH = 24
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type !== 'funnel') return
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
const sourceX = node.position.x
|
||||||
|
const rate = data.inflowRate || 1
|
||||||
|
|
||||||
|
data.overflowAllocations?.forEach((alloc) => {
|
||||||
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
||||||
|
const targetNode = nodes.find(n => n.id === alloc.targetId)
|
||||||
|
if (!targetNode) return
|
||||||
|
|
||||||
|
const targetX = targetNode.position.x
|
||||||
|
const goingRight = targetX > sourceX
|
||||||
|
const sourceHandle = goingRight ? 'outflow-right' : 'outflow-left'
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `outflow-${node.id}-${alloc.targetId}`,
|
||||||
|
source: node.id,
|
||||||
|
target: alloc.targetId,
|
||||||
|
sourceHandle,
|
||||||
|
targetHandle: undefined,
|
||||||
|
animated: true,
|
||||||
|
style: {
|
||||||
|
stroke: alloc.color,
|
||||||
|
strokeWidth,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: alloc.color,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
label: `${alloc.percentage}%`,
|
||||||
|
labelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
fill: alloc.color,
|
||||||
|
},
|
||||||
|
labelBgStyle: {
|
||||||
|
fill: 'white',
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
},
|
||||||
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
|
labelBgBorderRadius: 4,
|
||||||
|
data: {
|
||||||
|
allocation: alloc.percentage,
|
||||||
|
color: alloc.color,
|
||||||
|
edgeType: 'overflow' as const,
|
||||||
|
},
|
||||||
|
type: 'smoothstep',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
data.spendingAllocations?.forEach((alloc) => {
|
||||||
|
const flowValue = (alloc.percentage / 100) * rate
|
||||||
|
const strokeWidth = MIN_WIDTH + (flowValue / maxFlow) * (MAX_WIDTH - MIN_WIDTH)
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `spending-${node.id}-${alloc.targetId}`,
|
||||||
|
source: node.id,
|
||||||
|
target: alloc.targetId,
|
||||||
|
sourceHandle: 'spending-out',
|
||||||
|
animated: true,
|
||||||
|
style: {
|
||||||
|
stroke: alloc.color,
|
||||||
|
strokeWidth,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
markerEnd: {
|
||||||
|
type: MarkerType.ArrowClosed,
|
||||||
|
color: alloc.color,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
label: `${alloc.percentage}%`,
|
||||||
|
labelStyle: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
fill: alloc.color,
|
||||||
|
},
|
||||||
|
labelBgStyle: {
|
||||||
|
fill: 'white',
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
},
|
||||||
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
|
labelBgBorderRadius: 4,
|
||||||
|
data: {
|
||||||
|
allocation: alloc.percentage,
|
||||||
|
color: alloc.color,
|
||||||
|
edgeType: 'spending' as const,
|
||||||
|
},
|
||||||
|
type: 'smoothstep',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return edges
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowCanvasInnerProps {
|
||||||
|
initialNodes: FlowNode[]
|
||||||
|
mode: 'demo' | 'space'
|
||||||
|
onNodesChange?: (nodes: FlowNode[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasInner({ initialNodes: initNodes, mode, onNodesChange }: FlowCanvasInnerProps) {
|
||||||
|
const [nodes, setNodes, onNodesChangeHandler] = useNodesState(initNodes)
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(generateEdges(initNodes))
|
||||||
|
const [isSimulating, setIsSimulating] = useState(mode === 'demo')
|
||||||
|
const edgesRef = useRef(edges)
|
||||||
|
edgesRef.current = edges
|
||||||
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
// Notify parent of node changes for save/share
|
||||||
|
const nodesRef = useRef(nodes)
|
||||||
|
nodesRef.current = nodes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onNodesChange) {
|
||||||
|
onNodesChange(nodes as FlowNode[])
|
||||||
|
}
|
||||||
|
}, [nodes, onNodesChange])
|
||||||
|
|
||||||
|
// Smart edge regeneration
|
||||||
|
const allocationsKey = useMemo(() => {
|
||||||
|
return JSON.stringify(
|
||||||
|
nodes
|
||||||
|
.filter(n => n.type === 'funnel')
|
||||||
|
.map(n => {
|
||||||
|
const d = n.data as FunnelNodeData
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
overflow: d.overflowAllocations,
|
||||||
|
spending: d.spendingAllocations,
|
||||||
|
rate: d.inflowRate,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEdges(generateEdges(nodes as FlowNode[]))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allocationsKey])
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(params: Connection) => {
|
||||||
|
if (!params.source || !params.target) return
|
||||||
|
|
||||||
|
const isOverflow = params.sourceHandle?.startsWith('outflow')
|
||||||
|
const isSpending = params.sourceHandle === 'spending-out'
|
||||||
|
|
||||||
|
if (!isOverflow && !isSpending) return
|
||||||
|
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== params.source || node.type !== 'funnel') return node
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
|
||||||
|
if (isOverflow) {
|
||||||
|
const existing = data.overflowAllocations || []
|
||||||
|
if (existing.some(a => a.targetId === params.target)) return node
|
||||||
|
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||||
|
const redistributed = existing.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * existing.length / (existing.length + 1))
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
overflowAllocations: [
|
||||||
|
...redistributed,
|
||||||
|
{
|
||||||
|
targetId: params.target!,
|
||||||
|
percentage: newPct,
|
||||||
|
color: OVERFLOW_COLORS[existing.length % OVERFLOW_COLORS.length],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existing = data.spendingAllocations || []
|
||||||
|
if (existing.some(a => a.targetId === params.target)) return node
|
||||||
|
const newPct = existing.length === 0 ? 100 : Math.floor(100 / (existing.length + 1))
|
||||||
|
const redistributed = existing.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * existing.length / (existing.length + 1))
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
spendingAllocations: [
|
||||||
|
...redistributed,
|
||||||
|
{
|
||||||
|
targetId: params.target!,
|
||||||
|
percentage: newPct,
|
||||||
|
color: SPENDING_COLORS[existing.length % SPENDING_COLORS.length],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onReconnect = useCallback(
|
||||||
|
(oldEdge: FlowEdge, newConnection: Connection) => {
|
||||||
|
const edgeData = oldEdge.data
|
||||||
|
if (!edgeData || !newConnection.target) return
|
||||||
|
|
||||||
|
const oldTargetId = oldEdge.target
|
||||||
|
const newTargetId = newConnection.target
|
||||||
|
if (oldTargetId === newTargetId) return
|
||||||
|
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== oldEdge.source || node.type !== 'funnel') return node
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
|
||||||
|
if (edgeData.edgeType === 'overflow') {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
overflowAllocations: data.overflowAllocations.map(a =>
|
||||||
|
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
spendingAllocations: data.spendingAllocations.map(a =>
|
||||||
|
a.targetId === oldTargetId ? { ...a, targetId: newTargetId } : a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
[setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEdgesChange = useCallback(
|
||||||
|
(changes: Parameters<typeof onEdgesChange>[0]) => {
|
||||||
|
changes.forEach((change) => {
|
||||||
|
if (change.type === 'remove') {
|
||||||
|
const edge = edgesRef.current.find(e => e.id === change.id)
|
||||||
|
if (edge?.data) {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== edge.source || node.type !== 'funnel') return node
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
|
||||||
|
if (edge.data!.edgeType === 'overflow') {
|
||||||
|
const filtered = data.overflowAllocations.filter(a => a.targetId !== edge.target)
|
||||||
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
overflowAllocations: total > 0
|
||||||
|
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const filtered = data.spendingAllocations.filter(a => a.targetId !== edge.target)
|
||||||
|
const total = filtered.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
spendingAllocations: total > 0
|
||||||
|
? filtered.map(a => ({ ...a, percentage: Math.round((a.percentage / total) * 100) }))
|
||||||
|
: filtered,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onEdgesChange(changes)
|
||||||
|
},
|
||||||
|
[onEdgesChange, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add funnel node at viewport center
|
||||||
|
const addFunnel = useCallback(() => {
|
||||||
|
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 })
|
||||||
|
const newId = `funnel-${Date.now()}`
|
||||||
|
setNodes((nds) => [
|
||||||
|
...nds,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'funnel',
|
||||||
|
position: pos,
|
||||||
|
data: {
|
||||||
|
label: 'New Funnel',
|
||||||
|
currentValue: 0,
|
||||||
|
minThreshold: 10000,
|
||||||
|
maxThreshold: 40000,
|
||||||
|
maxCapacity: 50000,
|
||||||
|
inflowRate: 0,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
// Add outcome node at viewport center
|
||||||
|
const addOutcome = useCallback(() => {
|
||||||
|
const pos = screenToFlowPosition({ x: window.innerWidth / 2, y: window.innerHeight / 2 + 100 })
|
||||||
|
const newId = `outcome-${Date.now()}`
|
||||||
|
setNodes((nds) => [
|
||||||
|
...nds,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'outcome',
|
||||||
|
position: pos,
|
||||||
|
data: {
|
||||||
|
label: 'New Outcome',
|
||||||
|
description: '',
|
||||||
|
fundingReceived: 0,
|
||||||
|
fundingTarget: 20000,
|
||||||
|
status: 'not-started',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
// Simulation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSimulating) return
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setNodes((nds) =>
|
||||||
|
nds.map((node) => {
|
||||||
|
if (node.type === 'funnel') {
|
||||||
|
const data = node.data as FunnelNodeData
|
||||||
|
const change = (Math.random() - 0.45) * 300
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
currentValue: Math.max(0, Math.min(data.maxCapacity * 1.1, data.currentValue + change)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (node.type === 'outcome') {
|
||||||
|
const data = node.data as OutcomeNodeData
|
||||||
|
const change = Math.random() * 80
|
||||||
|
const newReceived = Math.min(data.fundingTarget * 1.05, data.fundingReceived + change)
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
fundingReceived: newReceived,
|
||||||
|
status: newReceived >= data.fundingTarget ? 'completed' :
|
||||||
|
data.status === 'not-started' && newReceived > 0 ? 'in-progress' : data.status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isSimulating, setNodes])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChangeHandler}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onReconnect={onReconnect}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgesReconnectable={true}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.15 }}
|
||||||
|
className="bg-slate-50"
|
||||||
|
connectionLineStyle={{ stroke: '#94a3b8', strokeWidth: 3 }}
|
||||||
|
isValidConnection={(connection) => connection.source !== connection.target}
|
||||||
|
defaultEdgeOptions={{ type: 'smoothstep' }}
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
|
||||||
|
<Controls className="bg-white border border-slate-200 rounded-lg shadow-sm" />
|
||||||
|
|
||||||
|
{/* Title Panel */}
|
||||||
|
<Panel position="top-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-4 m-4">
|
||||||
|
<h1 className="text-lg font-bold text-slate-800">Threshold-Based Flow Funding</h1>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
<span className="text-emerald-600">Inflows</span> (top) •
|
||||||
|
<span className="text-amber-600 ml-1">Overflow</span> (sides) •
|
||||||
|
<span className="text-blue-600 ml-1">Spending</span> (bottom)
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">
|
||||||
|
Drag handles to connect • Double-click funnels to edit • Select + Delete to remove edges
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Top-right Controls */}
|
||||||
|
<Panel position="top-right" className="m-4 flex gap-2">
|
||||||
|
{mode === 'space' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={addFunnel}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-amber-500 text-white hover:bg-amber-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Funnel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={addOutcome}
|
||||||
|
className="px-3 py-2 rounded-lg font-medium shadow-sm text-sm bg-pink-500 text-white hover:bg-pink-600 transition-all"
|
||||||
|
>
|
||||||
|
+ Outcome
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSimulating(!isSimulating)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium shadow-sm transition-all text-sm ${
|
||||||
|
isSimulating
|
||||||
|
? 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSimulating ? 'Pause' : 'Start'}
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<Panel position="bottom-left" className="bg-white rounded-lg shadow-lg border border-slate-200 p-3 m-4">
|
||||||
|
<div className="text-[10px] font-medium text-slate-500 uppercase tracking-wide mb-2">Flow Types</div>
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-slate-600">Inflows (top)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-slate-600">Overflow (sides)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-slate-600">Spending (bottom)</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-200 pt-1.5 mt-1.5">
|
||||||
|
<span className="text-[10px] text-slate-400">Edge width = relative flow amount</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props for the exported component
|
||||||
|
interface FlowCanvasProps {
|
||||||
|
initialNodes: FlowNode[]
|
||||||
|
mode?: 'demo' | 'space'
|
||||||
|
onNodesChange?: (nodes: FlowNode[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlowCanvas({ initialNodes, mode = 'demo', onNodesChange }: FlowCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<FlowCanvasInner initialNodes={initialNodes} mode={mode} onNodesChange={onNodesChange} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,837 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { Handle, Position, useReactFlow } from '@xyflow/react'
|
||||||
|
import type { NodeProps } from '@xyflow/react'
|
||||||
|
import type { FunnelNodeData, OutcomeNodeData } from '@/lib/types'
|
||||||
|
|
||||||
|
const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
|
const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
||||||
|
function FunnelNode({ data, selected, id }: NodeProps) {
|
||||||
|
const nodeData = data as FunnelNodeData
|
||||||
|
const { label, currentValue, maxCapacity, overflowAllocations = [], spendingAllocations = [] } = nodeData
|
||||||
|
|
||||||
|
const { getNode, setNodes } = useReactFlow()
|
||||||
|
|
||||||
|
const [minThreshold, setMinThreshold] = useState(nodeData.minThreshold)
|
||||||
|
const [maxThreshold, setMaxThreshold] = useState(nodeData.maxThreshold)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [draggingPie, setDraggingPie] = useState<{ type: 'overflow' | 'spending', index: number } | null>(null)
|
||||||
|
const [localOverflow, setLocalOverflow] = useState(overflowAllocations)
|
||||||
|
const [localSpending, setLocalSpending] = useState(spendingAllocations)
|
||||||
|
const [showAddOutflow, setShowAddOutflow] = useState(false)
|
||||||
|
const [showAddOutcome, setShowAddOutcome] = useState(false)
|
||||||
|
const [newItemName, setNewItemName] = useState('')
|
||||||
|
|
||||||
|
const sliderRef = useRef<HTMLDivElement>(null)
|
||||||
|
const overflowPieRef = useRef<SVGSVGElement>(null)
|
||||||
|
const spendingPieRef = useRef<SVGSVGElement>(null)
|
||||||
|
|
||||||
|
const isOverflowing = currentValue > maxThreshold
|
||||||
|
const isCritical = currentValue < minThreshold
|
||||||
|
const fillPercent = Math.min(100, (currentValue / maxCapacity) * 100)
|
||||||
|
|
||||||
|
const width = 160
|
||||||
|
const height = 140
|
||||||
|
|
||||||
|
const minPercent = minThreshold / maxCapacity
|
||||||
|
const maxPercent = maxThreshold / maxCapacity
|
||||||
|
|
||||||
|
const overflowZoneHeight = (1 - maxPercent) * height * 0.4 + 15
|
||||||
|
const healthyZoneHeight = (maxPercent - minPercent) * height * 0.8 + 30
|
||||||
|
const drainZoneHeight = height - overflowZoneHeight - healthyZoneHeight
|
||||||
|
|
||||||
|
const topWidth = 130
|
||||||
|
const midWidth = 100
|
||||||
|
const bottomWidth = 30
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setLocalOverflow([...overflowAllocations])
|
||||||
|
setLocalSpending([...spendingAllocations])
|
||||||
|
setIsEditing(true)
|
||||||
|
}, [overflowAllocations, spendingAllocations])
|
||||||
|
|
||||||
|
const handleCloseEdit = useCallback(() => {
|
||||||
|
setNodes((nds) => nds.map((node) => {
|
||||||
|
if (node.id !== id) return node
|
||||||
|
const prevData = node.data as FunnelNodeData
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...prevData,
|
||||||
|
overflowAllocations: localOverflow,
|
||||||
|
spendingAllocations: localSpending,
|
||||||
|
minThreshold,
|
||||||
|
maxThreshold,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setIsEditing(false)
|
||||||
|
setShowAddOutflow(false)
|
||||||
|
setShowAddOutcome(false)
|
||||||
|
setNewItemName('')
|
||||||
|
}, [id, localOverflow, localSpending, minThreshold, maxThreshold, setNodes])
|
||||||
|
|
||||||
|
const [draggingThreshold, setDraggingThreshold] = useState<'min' | 'max' | null>(null)
|
||||||
|
|
||||||
|
const handleThresholdMouseDown = useCallback((e: React.MouseEvent, type: 'min' | 'max') => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDraggingThreshold(type)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingThreshold || !sliderRef.current) return
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const rect = sliderRef.current!.getBoundingClientRect()
|
||||||
|
const x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
|
||||||
|
const value = Math.round((x / rect.width) * maxCapacity)
|
||||||
|
|
||||||
|
if (draggingThreshold === 'min') {
|
||||||
|
setMinThreshold(Math.min(value, maxThreshold - 1000))
|
||||||
|
} else {
|
||||||
|
setMaxThreshold(Math.max(value, minThreshold + 1000))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUp = () => setDraggingThreshold(null)
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove)
|
||||||
|
window.addEventListener('mouseup', handleUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove)
|
||||||
|
window.removeEventListener('mouseup', handleUp)
|
||||||
|
}
|
||||||
|
}, [draggingThreshold, maxCapacity, minThreshold, maxThreshold])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draggingPie) return
|
||||||
|
|
||||||
|
const handleMove = (e: MouseEvent) => {
|
||||||
|
const pieRef = draggingPie.type === 'overflow' ? overflowPieRef.current : spendingPieRef.current
|
||||||
|
if (!pieRef) return
|
||||||
|
|
||||||
|
const rect = pieRef.getBoundingClientRect()
|
||||||
|
const centerX = rect.left + rect.width / 2
|
||||||
|
const centerY = rect.top + rect.height / 2
|
||||||
|
|
||||||
|
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI) + 90
|
||||||
|
const normalizedAngle = ((angle % 360) + 360) % 360
|
||||||
|
const percentage = Math.round((normalizedAngle / 360) * 100)
|
||||||
|
|
||||||
|
if (draggingPie.type === 'overflow') {
|
||||||
|
setLocalOverflow(prev => {
|
||||||
|
const newAllocs = [...prev]
|
||||||
|
if (newAllocs.length > 1) {
|
||||||
|
const otherIdx = (draggingPie.index + 1) % newAllocs.length
|
||||||
|
const newCurrent = Math.max(5, Math.min(95, percentage))
|
||||||
|
newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent }
|
||||||
|
newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) }
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setLocalSpending(prev => {
|
||||||
|
const newAllocs = [...prev]
|
||||||
|
if (newAllocs.length > 1) {
|
||||||
|
const otherIdx = (draggingPie.index + 1) % newAllocs.length
|
||||||
|
const newCurrent = Math.max(5, Math.min(95, percentage))
|
||||||
|
newAllocs[draggingPie.index] = { ...newAllocs[draggingPie.index], percentage: newCurrent }
|
||||||
|
newAllocs[otherIdx] = { ...newAllocs[otherIdx], percentage: 100 - newCurrent - newAllocs.filter((_, i) => i !== draggingPie.index && i !== otherIdx).reduce((s, a) => s + a.percentage, 0) }
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUp = () => setDraggingPie(null)
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMove)
|
||||||
|
window.addEventListener('mouseup', handleUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMove)
|
||||||
|
window.removeEventListener('mouseup', handleUp)
|
||||||
|
}
|
||||||
|
}, [draggingPie])
|
||||||
|
|
||||||
|
const handleAddOutflow = useCallback(() => {
|
||||||
|
if (!newItemName.trim()) return
|
||||||
|
|
||||||
|
const currentNode = getNode(id)
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const newId = `funnel-${Date.now()}`
|
||||||
|
const newNodeData: FunnelNodeData = {
|
||||||
|
label: newItemName,
|
||||||
|
currentValue: 0,
|
||||||
|
minThreshold: 10000,
|
||||||
|
maxThreshold: 40000,
|
||||||
|
maxCapacity: 50000,
|
||||||
|
inflowRate: 0,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes((nodes) => [
|
||||||
|
...nodes,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: currentNode.position.x + 250, y: currentNode.position.y },
|
||||||
|
data: newNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const newAllocation = {
|
||||||
|
targetId: newId,
|
||||||
|
percentage: localOverflow.length === 0 ? 100 : Math.floor(100 / (localOverflow.length + 1)),
|
||||||
|
color: OVERFLOW_COLORS[localOverflow.length % OVERFLOW_COLORS.length],
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOverflow = localOverflow.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * localOverflow.length / (localOverflow.length + 1))
|
||||||
|
}))
|
||||||
|
newOverflow.push(newAllocation)
|
||||||
|
|
||||||
|
setLocalOverflow(newOverflow)
|
||||||
|
setShowAddOutflow(false)
|
||||||
|
setNewItemName('')
|
||||||
|
}, [newItemName, id, getNode, setNodes, localOverflow])
|
||||||
|
|
||||||
|
const handleAddOutcome = useCallback(() => {
|
||||||
|
if (!newItemName.trim()) return
|
||||||
|
|
||||||
|
const currentNode = getNode(id)
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const newId = `outcome-${Date.now()}`
|
||||||
|
const newNodeData: OutcomeNodeData = {
|
||||||
|
label: newItemName,
|
||||||
|
description: '',
|
||||||
|
fundingReceived: 0,
|
||||||
|
fundingTarget: 20000,
|
||||||
|
status: 'not-started',
|
||||||
|
}
|
||||||
|
|
||||||
|
setNodes((nodes) => [
|
||||||
|
...nodes,
|
||||||
|
{
|
||||||
|
id: newId,
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: currentNode.position.x, y: currentNode.position.y + 300 },
|
||||||
|
data: newNodeData,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const newAllocation = {
|
||||||
|
targetId: newId,
|
||||||
|
percentage: localSpending.length === 0 ? 100 : Math.floor(100 / (localSpending.length + 1)),
|
||||||
|
color: SPENDING_COLORS[localSpending.length % SPENDING_COLORS.length],
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSpending = localSpending.map(a => ({
|
||||||
|
...a,
|
||||||
|
percentage: Math.floor(a.percentage * localSpending.length / (localSpending.length + 1))
|
||||||
|
}))
|
||||||
|
newSpending.push(newAllocation)
|
||||||
|
|
||||||
|
setLocalSpending(newSpending)
|
||||||
|
setShowAddOutcome(false)
|
||||||
|
setNewItemName('')
|
||||||
|
}, [newItemName, id, getNode, setNodes, localSpending])
|
||||||
|
|
||||||
|
const handleRemoveOutflow = useCallback((index: number) => {
|
||||||
|
setLocalOverflow(prev => {
|
||||||
|
const newAllocs = prev.filter((_, i) => i !== index)
|
||||||
|
if (newAllocs.length > 0) {
|
||||||
|
const total = newAllocs.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) }))
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleRemoveSpending = useCallback((index: number) => {
|
||||||
|
setLocalSpending(prev => {
|
||||||
|
const newAllocs = prev.filter((_, i) => i !== index)
|
||||||
|
if (newAllocs.length > 0) {
|
||||||
|
const total = newAllocs.reduce((s, a) => s + a.percentage, 0)
|
||||||
|
return newAllocs.map(a => ({ ...a, percentage: Math.round(a.percentage / total * 100) }))
|
||||||
|
}
|
||||||
|
return newAllocs
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const renderPieChart = (allocations: typeof overflowAllocations, colors: string[], type: 'overflow' | 'spending', size: number) => {
|
||||||
|
if (allocations.length === 0) return null
|
||||||
|
|
||||||
|
const center = size / 2
|
||||||
|
const radius = size / 2 - 4
|
||||||
|
let currentAngle = -90
|
||||||
|
|
||||||
|
return allocations.map((alloc, idx) => {
|
||||||
|
const angle = (alloc.percentage / 100) * 360
|
||||||
|
const startAngle = currentAngle
|
||||||
|
const endAngle = currentAngle + angle
|
||||||
|
currentAngle = endAngle
|
||||||
|
|
||||||
|
const startRad = (startAngle * Math.PI) / 180
|
||||||
|
const endRad = (endAngle * Math.PI) / 180
|
||||||
|
|
||||||
|
const x1 = center + radius * Math.cos(startRad)
|
||||||
|
const y1 = center + radius * Math.sin(startRad)
|
||||||
|
const x2 = center + radius * Math.cos(endRad)
|
||||||
|
const y2 = center + radius * Math.sin(endRad)
|
||||||
|
|
||||||
|
const largeArc = angle > 180 ? 1 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={idx}
|
||||||
|
d={`M ${center} ${center} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} Z`}
|
||||||
|
fill={alloc.color || colors[idx % colors.length]}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="cursor-grab hover:opacity-80"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setDraggingPie({ type, index: idx })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSimpleBars = (allocations: typeof overflowAllocations, colors: string[], direction: 'horizontal' | 'vertical') => {
|
||||||
|
if (allocations.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex ${direction === 'horizontal' ? 'flex-row h-2' : 'flex-col w-2'} rounded overflow-hidden`}>
|
||||||
|
{allocations.map((alloc, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="transition-all"
|
||||||
|
style={{
|
||||||
|
backgroundColor: alloc.color || colors[idx % colors.length],
|
||||||
|
[direction === 'horizontal' ? 'width' : 'height']: `${alloc.percentage}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOverflow = overflowAllocations.length > 0
|
||||||
|
const hasSpending = spendingAllocations.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-white rounded-xl shadow-lg border-2 transition-all duration-200
|
||||||
|
${selected ? 'border-blue-500 shadow-blue-200' : 'border-slate-200'}
|
||||||
|
${isEditing ? 'ring-2 ring-blue-400 ring-offset-2' : ''}
|
||||||
|
`}
|
||||||
|
style={{ width: width + 60 }}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-4 !h-4 !bg-emerald-500 !border-2 !border-white !-top-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-slate-800 text-sm">{label}</span>
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||||
|
isOverflowing ? 'bg-amber-100 text-amber-700' :
|
||||||
|
isCritical ? 'bg-red-100 text-red-700' : 'bg-emerald-100 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{isOverflowing ? 'OVER' : isCritical ? 'LOW' : 'OK'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2l-6 6h4v6h4v-6h4z"/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-[9px] text-emerald-600 uppercase font-medium">Inflow</span>
|
||||||
|
<svg className="w-3 h-3 text-emerald-500" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2l-6 6h4v6h4v-6h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg width={width} height={height} className="mx-auto" style={{ overflow: 'visible' }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`fill-${id}`} x1="0%" y1="100%" x2="0%" y2="0%">
|
||||||
|
<stop offset="0%" stopColor={isOverflowing ? '#fbbf24' : isCritical ? '#f87171' : '#34d399'} />
|
||||||
|
<stop offset="100%" stopColor={isOverflowing ? '#fde68a' : isCritical ? '#fca5a5' : '#a7f3d0'} />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id={`funnel-clip-${id}`}>
|
||||||
|
<path d={`
|
||||||
|
M ${width/2 - topWidth/2} 0
|
||||||
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
|
L ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
|
Z
|
||||||
|
`} />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M ${width/2 - topWidth/2} 0
|
||||||
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="#fef3c7"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
x={width/2 - midWidth/2}
|
||||||
|
y={overflowZoneHeight}
|
||||||
|
width={midWidth}
|
||||||
|
height={healthyZoneHeight}
|
||||||
|
fill="#d1fae5"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="#e2e8f0"
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g clipPath={`url(#funnel-clip-${id})`}>
|
||||||
|
<rect
|
||||||
|
x={0}
|
||||||
|
y={height - (height * fillPercent / 100)}
|
||||||
|
width={width}
|
||||||
|
height={height * fillPercent / 100}
|
||||||
|
fill={`url(#fill-${id})`}
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="y"
|
||||||
|
values={`${height - (height * fillPercent / 100)};${height - (height * fillPercent / 100) - 3};${height - (height * fillPercent / 100)}`}
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
|
||||||
|
<ellipse
|
||||||
|
cx={width/2}
|
||||||
|
cy={height - (height * fillPercent / 100)}
|
||||||
|
rx={30}
|
||||||
|
ry={3}
|
||||||
|
fill="rgba(255,255,255,0.4)"
|
||||||
|
>
|
||||||
|
<animate
|
||||||
|
attributeName="rx"
|
||||||
|
values="25;35;25"
|
||||||
|
dur="2s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</ellipse>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={width/2 - midWidth/2 - 5}
|
||||||
|
y1={overflowZoneHeight}
|
||||||
|
x2={width/2 + midWidth/2 + 5}
|
||||||
|
y2={overflowZoneHeight}
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={width/2 + midWidth/2 + 8}
|
||||||
|
y={overflowZoneHeight + 4}
|
||||||
|
fontSize="8"
|
||||||
|
fill="#f59e0b"
|
||||||
|
fontWeight="bold"
|
||||||
|
>MAX</text>
|
||||||
|
|
||||||
|
<line
|
||||||
|
x1={width/2 - midWidth/2 - 5}
|
||||||
|
y1={overflowZoneHeight + healthyZoneHeight}
|
||||||
|
x2={width/2 + midWidth/2 + 5}
|
||||||
|
y2={overflowZoneHeight + healthyZoneHeight}
|
||||||
|
stroke="#ef4444"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={width/2 + midWidth/2 + 8}
|
||||||
|
y={overflowZoneHeight + healthyZoneHeight + 4}
|
||||||
|
fontSize="8"
|
||||||
|
fill="#ef4444"
|
||||||
|
fontWeight="bold"
|
||||||
|
>MIN</text>
|
||||||
|
|
||||||
|
<path
|
||||||
|
d={`
|
||||||
|
M ${width/2 - topWidth/2} 0
|
||||||
|
Q ${width/2 - topWidth/2 - 10} ${overflowZoneHeight/2}, ${width/2 - midWidth/2} ${overflowZoneHeight}
|
||||||
|
L ${width/2 - midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 - bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + bottomWidth/2} ${height}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight + healthyZoneHeight}
|
||||||
|
L ${width/2 + midWidth/2} ${overflowZoneHeight}
|
||||||
|
Q ${width/2 + topWidth/2 + 10} ${overflowZoneHeight/2}, ${width/2 + topWidth/2} 0
|
||||||
|
Z
|
||||||
|
`}
|
||||||
|
fill="none"
|
||||||
|
stroke="#475569"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isOverflowing && hasOverflow && (
|
||||||
|
<>
|
||||||
|
<g transform={`translate(${width/2 - topWidth/2 - 20}, ${overflowZoneHeight/2})`}>
|
||||||
|
<path d="M 0 0 L -10 -5 L -10 5 Z" fill="#f59e0b">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="1s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
<g transform={`translate(${width/2 + topWidth/2 + 20}, ${overflowZoneHeight/2})`}>
|
||||||
|
<path d="M 0 0 L 10 -5 L 10 5 Z" fill="#f59e0b">
|
||||||
|
<animate attributeName="opacity" values="1;0.4;1" dur="1s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSpending && (
|
||||||
|
<g transform={`translate(${width/2}, ${height + 5})`}>
|
||||||
|
<path d="M 0 0 L -5 -8 L 5 -8 Z" fill="#3b82f6">
|
||||||
|
<animate attributeName="opacity" values="1;0.5;1" dur="1.5s" repeatCount="indefinite" />
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<span className={`text-base font-bold font-mono ${
|
||||||
|
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
${Math.floor(currentValue / 1000)}k
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{hasOverflow && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[8px] text-amber-600 uppercase w-12">← Out →</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{renderSimpleBars(overflowAllocations, OVERFLOW_COLORS, 'horizontal')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasSpending && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[8px] text-blue-600 uppercase w-12">↓ Fund</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{renderSimpleBars(spendingAllocations, SPENDING_COLORS, 'horizontal')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-2">
|
||||||
|
<span className="text-[8px] text-slate-400">Double-click to edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Left}
|
||||||
|
id="outflow-left"
|
||||||
|
className="!w-4 !h-4 !bg-amber-500 !border-2 !border-white !rounded-full"
|
||||||
|
style={{ top: '30%' }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
id="outflow-right"
|
||||||
|
className="!w-4 !h-4 !bg-amber-500 !border-2 !border-white !rounded-full"
|
||||||
|
style={{ top: '30%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
id="spending-out"
|
||||||
|
className="!w-4 !h-4 !bg-blue-500 !border-2 !border-white !-bottom-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={handleCloseEdit}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl p-6 min-w-[480px] max-w-xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseEdit}
|
||||||
|
className="text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className={`text-3xl font-bold font-mono ${
|
||||||
|
isOverflowing ? 'text-amber-600' : isCritical ? 'text-red-600' : 'text-emerald-600'
|
||||||
|
}`}>
|
||||||
|
${Math.floor(currentValue).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-sm ml-2">/ ${maxCapacity.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between text-xs text-slate-500 mb-2">
|
||||||
|
<span>MIN: <span className="text-red-600 font-mono font-medium">${(minThreshold/1000).toFixed(0)}k</span></span>
|
||||||
|
<span>MAX: <span className="text-amber-600 font-mono font-medium">${(maxThreshold/1000).toFixed(0)}k</span></span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={sliderRef}
|
||||||
|
className="relative h-6 bg-slate-100 rounded-full cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-red-200"
|
||||||
|
style={{ left: 0, width: `${(minThreshold / maxCapacity) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-emerald-200"
|
||||||
|
style={{
|
||||||
|
left: `${(minThreshold / maxCapacity) * 100}%`,
|
||||||
|
width: `${((maxThreshold - minThreshold) / maxCapacity) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-amber-200"
|
||||||
|
style={{ left: `${(maxThreshold / maxCapacity) * 100}%`, right: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-slate-800 rounded"
|
||||||
|
style={{ left: `${Math.min(100, (currentValue / maxCapacity) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-red-500 border-2 border-white rounded-full shadow-lg cursor-grab ${draggingThreshold === 'min' ? 'cursor-grabbing scale-110' : 'hover:scale-105'}`}
|
||||||
|
style={{ left: `calc(${(minThreshold / maxCapacity) * 100}% - 10px)` }}
|
||||||
|
onMouseDown={(e) => handleThresholdMouseDown(e, 'min')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute top-1/2 -translate-y-1/2 w-5 h-5 bg-amber-500 border-2 border-white rounded-full shadow-lg cursor-grab ${draggingThreshold === 'max' ? 'cursor-grabbing scale-110' : 'hover:scale-105'}`}
|
||||||
|
style={{ left: `calc(${(maxThreshold / maxCapacity) * 100}% - 10px)` }}
|
||||||
|
onMouseDown={(e) => handleThresholdMouseDown(e, 'max')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||||
|
<span>$0</span>
|
||||||
|
<span className="text-slate-600 font-medium">Drag handles to adjust</span>
|
||||||
|
<span>${(maxCapacity/1000).toFixed(0)}k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-amber-50 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold text-amber-700">→ Outflows</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddOutflow(true)}
|
||||||
|
className="w-6 h-6 bg-amber-500 text-white rounded-full flex items-center justify-center hover:bg-amber-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localOverflow.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<svg ref={overflowPieRef} width={100} height={100} className="mx-auto cursor-pointer">
|
||||||
|
{renderPieChart(localOverflow, OVERFLOW_COLORS, 'overflow', 100)}
|
||||||
|
<circle cx={50} cy={50} r={20} fill="white" />
|
||||||
|
</svg>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{localOverflow.map((alloc, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-xs group">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded flex-shrink-0"
|
||||||
|
style={{ backgroundColor: alloc.color || OVERFLOW_COLORS[idx] }}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-600 truncate flex-1">{alloc.targetId}</span>
|
||||||
|
<span className="text-amber-600 font-mono">{alloc.percentage}%</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveOutflow(idx)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-amber-600/60 text-center py-4">No outflows yet</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddOutflow && (
|
||||||
|
<div className="mt-3 p-2 bg-white rounded-lg border border-amber-200">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => setNewItemName(e.target.value)}
|
||||||
|
placeholder="New funnel name..."
|
||||||
|
className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleAddOutflow}
|
||||||
|
className="flex-1 text-xs px-2 py-1 bg-amber-500 text-white rounded hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAddOutflow(false); setNewItemName(''); }}
|
||||||
|
className="text-xs px-2 py-1 text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold text-blue-700">↓ Outcomes</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddOutcome(true)}
|
||||||
|
className="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center hover:bg-blue-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{localSpending.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<svg ref={spendingPieRef} width={100} height={100} className="mx-auto cursor-pointer">
|
||||||
|
{renderPieChart(localSpending, SPENDING_COLORS, 'spending', 100)}
|
||||||
|
<circle cx={50} cy={50} r={20} fill="white" />
|
||||||
|
</svg>
|
||||||
|
<div className="mt-3 space-y-1">
|
||||||
|
{localSpending.map((alloc, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2 text-xs group">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded flex-shrink-0"
|
||||||
|
style={{ backgroundColor: alloc.color || SPENDING_COLORS[idx] }}
|
||||||
|
/>
|
||||||
|
<span className="text-slate-600 truncate flex-1">{alloc.targetId}</span>
|
||||||
|
<span className="text-blue-600 font-mono">{alloc.percentage}%</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveSpending(idx)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-blue-600/60 text-center py-4">No outcomes yet</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAddOutcome && (
|
||||||
|
<div className="mt-3 p-2 bg-white rounded-lg border border-blue-200">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newItemName}
|
||||||
|
onChange={(e) => setNewItemName(e.target.value)}
|
||||||
|
placeholder="New outcome name..."
|
||||||
|
className="w-full text-xs px-2 py-1 border border-slate-200 rounded mb-2"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleAddOutcome}
|
||||||
|
className="flex-1 text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowAddOutcome(false); setNewItemName(''); }}
|
||||||
|
className="text-xs px-2 py-1 text-slate-500 hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-slate-400 mt-4">
|
||||||
|
Drag pie slices to adjust • Click + to add new items
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handleCloseEdit}
|
||||||
|
className="px-6 py-2 bg-slate-800 text-white rounded-lg hover:bg-slate-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(FunnelNode)
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { Handle, Position } from '@xyflow/react'
|
||||||
|
import type { NodeProps } from '@xyflow/react'
|
||||||
|
import type { OutcomeNodeData } from '@/lib/types'
|
||||||
|
|
||||||
|
function OutcomeNode({ data, selected }: NodeProps) {
|
||||||
|
const nodeData = data as OutcomeNodeData
|
||||||
|
const { label, description, fundingReceived, fundingTarget, status } = nodeData
|
||||||
|
|
||||||
|
const progress = fundingTarget > 0 ? Math.min(100, (fundingReceived / fundingTarget) * 100) : 0
|
||||||
|
const isFunded = fundingReceived >= fundingTarget
|
||||||
|
const isPartial = fundingReceived > 0 && fundingReceived < fundingTarget
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
'not-started': { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-300' },
|
||||||
|
'in-progress': { bg: 'bg-blue-100', text: 'text-blue-700', border: 'border-blue-300' },
|
||||||
|
'completed': { bg: 'bg-emerald-100', text: 'text-emerald-700', border: 'border-emerald-300' },
|
||||||
|
'blocked': { bg: 'bg-red-100', text: 'text-red-700', border: 'border-red-300' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = statusColors[status] || statusColors['not-started']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
bg-white rounded-lg shadow-lg border-2 min-w-[200px] max-w-[240px]
|
||||||
|
transition-all duration-200
|
||||||
|
${selected ? 'border-pink-500 shadow-pink-200' : 'border-slate-200'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
className="!w-4 !h-4 !bg-pink-500 !border-2 !border-white !-top-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="px-3 py-2 border-b border-slate-100 bg-gradient-to-r from-pink-50 to-purple-50 rounded-t-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-6 h-6 bg-pink-500 rounded flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-4 h-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-slate-800 text-sm truncate">{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-slate-500 line-clamp-2">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium uppercase tracking-wide ${colors.bg} ${colors.text}`}>
|
||||||
|
{status.replace('-', ' ')}
|
||||||
|
</span>
|
||||||
|
{isFunded && (
|
||||||
|
<svg className="w-4 h-4 text-emerald-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-[10px] text-slate-500 uppercase tracking-wide">Funding</span>
|
||||||
|
<span className="text-xs font-mono text-slate-700">
|
||||||
|
${Math.floor(fundingReceived).toLocaleString()} / ${fundingTarget.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-500 ${
|
||||||
|
isFunded ? 'bg-emerald-500' : isPartial ? 'bg-blue-500' : 'bg-slate-300'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-right mt-0.5">
|
||||||
|
<span className="text-[10px] font-medium text-slate-500">{progress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(OutcomeNode)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
services:
|
||||||
|
rfunds-online:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.rfunds.rule=Host(`rfunds.online`) || Host(`www.rfunds.online`)"
|
||||||
|
- "traefik.http.services.rfunds.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- traefik-public
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData } from './types'
|
||||||
|
|
||||||
|
// Colors for allocations
|
||||||
|
export const SPENDING_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#10b981', '#6366f1']
|
||||||
|
export const OVERFLOW_COLORS = ['#f59e0b', '#ef4444', '#f97316', '#eab308', '#dc2626', '#ea580c']
|
||||||
|
|
||||||
|
// Demo preset: Treasury → 3 sub-funnels → 7 outcomes
|
||||||
|
export const demoNodes: FlowNode[] = [
|
||||||
|
// Main Treasury Funnel (top center)
|
||||||
|
{
|
||||||
|
id: 'treasury',
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 630, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Treasury',
|
||||||
|
currentValue: 85000,
|
||||||
|
minThreshold: 20000,
|
||||||
|
maxThreshold: 70000,
|
||||||
|
maxCapacity: 100000,
|
||||||
|
inflowRate: 1000,
|
||||||
|
overflowAllocations: [
|
||||||
|
{ targetId: 'public-goods', percentage: 40, color: OVERFLOW_COLORS[0] },
|
||||||
|
{ targetId: 'research', percentage: 35, color: OVERFLOW_COLORS[1] },
|
||||||
|
{ targetId: 'emergency', percentage: 25, color: OVERFLOW_COLORS[2] },
|
||||||
|
],
|
||||||
|
spendingAllocations: [
|
||||||
|
{ targetId: 'treasury-ops', percentage: 100, color: SPENDING_COLORS[0] },
|
||||||
|
],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
// Sub-funnels (middle row)
|
||||||
|
{
|
||||||
|
id: 'public-goods',
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 170, y: 450 },
|
||||||
|
data: {
|
||||||
|
label: 'Public Goods',
|
||||||
|
currentValue: 45000,
|
||||||
|
minThreshold: 15000,
|
||||||
|
maxThreshold: 50000,
|
||||||
|
maxCapacity: 70000,
|
||||||
|
inflowRate: 400,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [
|
||||||
|
{ targetId: 'pg-infra', percentage: 50, color: SPENDING_COLORS[0] },
|
||||||
|
{ targetId: 'pg-education', percentage: 30, color: SPENDING_COLORS[1] },
|
||||||
|
{ targetId: 'pg-tooling', percentage: 20, color: SPENDING_COLORS[2] },
|
||||||
|
],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research',
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 975, y: 450 },
|
||||||
|
data: {
|
||||||
|
label: 'Research',
|
||||||
|
currentValue: 28000,
|
||||||
|
minThreshold: 20000,
|
||||||
|
maxThreshold: 45000,
|
||||||
|
maxCapacity: 60000,
|
||||||
|
inflowRate: 350,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [
|
||||||
|
{ targetId: 'research-grants', percentage: 70, color: SPENDING_COLORS[0] },
|
||||||
|
{ targetId: 'research-papers', percentage: 30, color: SPENDING_COLORS[1] },
|
||||||
|
],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emergency',
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 1320, y: 450 },
|
||||||
|
data: {
|
||||||
|
label: 'Emergency',
|
||||||
|
currentValue: 12000,
|
||||||
|
minThreshold: 25000,
|
||||||
|
maxThreshold: 60000,
|
||||||
|
maxCapacity: 80000,
|
||||||
|
inflowRate: 250,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [
|
||||||
|
{ targetId: 'emergency-response', percentage: 100, color: SPENDING_COLORS[0] },
|
||||||
|
],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
// Outcome nodes (bottom row)
|
||||||
|
{
|
||||||
|
id: 'pg-infra',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: -50, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Infrastructure',
|
||||||
|
description: 'Core infrastructure development',
|
||||||
|
fundingReceived: 22000,
|
||||||
|
fundingTarget: 30000,
|
||||||
|
status: 'in-progress',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pg-education',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 180, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Education',
|
||||||
|
description: 'Developer education programs',
|
||||||
|
fundingReceived: 12000,
|
||||||
|
fundingTarget: 20000,
|
||||||
|
status: 'in-progress',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pg-tooling',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 410, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Dev Tooling',
|
||||||
|
description: 'Open-source developer tools',
|
||||||
|
fundingReceived: 5000,
|
||||||
|
fundingTarget: 15000,
|
||||||
|
status: 'not-started',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'treasury-ops',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 640, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Treasury Ops',
|
||||||
|
description: 'Day-to-day treasury management',
|
||||||
|
fundingReceived: 15000,
|
||||||
|
fundingTarget: 25000,
|
||||||
|
status: 'in-progress',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research-grants',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 870, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Grants',
|
||||||
|
description: 'Academic research grants',
|
||||||
|
fundingReceived: 18000,
|
||||||
|
fundingTarget: 25000,
|
||||||
|
status: 'in-progress',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research-papers',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 1100, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Papers',
|
||||||
|
description: 'Peer-reviewed publications',
|
||||||
|
fundingReceived: 8000,
|
||||||
|
fundingTarget: 10000,
|
||||||
|
status: 'in-progress',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emergency-response',
|
||||||
|
type: 'outcome',
|
||||||
|
position: { x: 1330, y: 900 },
|
||||||
|
data: {
|
||||||
|
label: 'Response Fund',
|
||||||
|
description: 'Rapid response for critical issues',
|
||||||
|
fundingReceived: 5000,
|
||||||
|
fundingTarget: 50000,
|
||||||
|
status: 'not-started',
|
||||||
|
} as OutcomeNodeData,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Empty starter for user-created spaces
|
||||||
|
export const starterNodes: FlowNode[] = [
|
||||||
|
{
|
||||||
|
id: 'treasury-1',
|
||||||
|
type: 'funnel',
|
||||||
|
position: { x: 400, y: 50 },
|
||||||
|
data: {
|
||||||
|
label: 'My Treasury',
|
||||||
|
currentValue: 50000,
|
||||||
|
minThreshold: 10000,
|
||||||
|
maxThreshold: 40000,
|
||||||
|
maxCapacity: 60000,
|
||||||
|
inflowRate: 500,
|
||||||
|
overflowAllocations: [],
|
||||||
|
spendingAllocations: [],
|
||||||
|
} as FunnelNodeData,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
|
||||||
|
import type { FlowNode, SpaceConfig } from './types'
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'rfunds-space-'
|
||||||
|
|
||||||
|
interface SerializableState {
|
||||||
|
nodes: FlowNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeState(nodes: FlowNode[]): string {
|
||||||
|
const state: SerializableState = { nodes }
|
||||||
|
const json = JSON.stringify(state)
|
||||||
|
return compressToEncodedURIComponent(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserializeState(compressed: string): { nodes: FlowNode[] } | null {
|
||||||
|
try {
|
||||||
|
const json = decompressFromEncodedURIComponent(compressed)
|
||||||
|
if (!json) return null
|
||||||
|
const state = JSON.parse(json) as SerializableState
|
||||||
|
if (!state.nodes || !Array.isArray(state.nodes)) return null
|
||||||
|
return { nodes: state.nodes }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveToLocal(name: string, nodes: FlowNode[]): void {
|
||||||
|
const config: SpaceConfig = {
|
||||||
|
name,
|
||||||
|
nodes,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if exists to preserve createdAt
|
||||||
|
const existing = loadFromLocal(name)
|
||||||
|
if (existing) {
|
||||||
|
config.createdAt = existing.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(STORAGE_PREFIX + name, JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFromLocal(name: string): SpaceConfig | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_PREFIX + name)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw) as SpaceConfig
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFromLocal(name: string): void {
|
||||||
|
localStorage.removeItem(STORAGE_PREFIX + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSavedSpaces(): SpaceConfig[] {
|
||||||
|
const spaces: SpaceConfig[] = []
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i)
|
||||||
|
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(localStorage.getItem(key)!) as SpaceConfig
|
||||||
|
spaces.push(config)
|
||||||
|
} catch {
|
||||||
|
// skip corrupt entries
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spaces.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
|
||||||
|
// Overflow allocation - funds flowing to OTHER FUNNELS when above max threshold
|
||||||
|
export interface OverflowAllocation {
|
||||||
|
targetId: string
|
||||||
|
percentage: number // 0-100
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spending allocation - funds flowing DOWN to OUTCOMES/OUTPUTS
|
||||||
|
export interface SpendingAllocation {
|
||||||
|
targetId: string
|
||||||
|
percentage: number // 0-100
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelNodeData {
|
||||||
|
label: string
|
||||||
|
currentValue: number
|
||||||
|
minThreshold: number
|
||||||
|
maxThreshold: number
|
||||||
|
maxCapacity: number
|
||||||
|
inflowRate: number
|
||||||
|
// Overflow goes SIDEWAYS to other funnels
|
||||||
|
overflowAllocations: OverflowAllocation[]
|
||||||
|
// Spending goes DOWN to outcomes/outputs
|
||||||
|
spendingAllocations: SpendingAllocation[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeNodeData {
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
fundingReceived: number
|
||||||
|
fundingTarget: number
|
||||||
|
status: 'not-started' | 'in-progress' | 'completed' | 'blocked'
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowNode = Node<FunnelNodeData | OutcomeNodeData>
|
||||||
|
|
||||||
|
export interface FlowEdgeData {
|
||||||
|
allocation: number // percentage 0-100
|
||||||
|
color: string
|
||||||
|
edgeType: 'overflow' | 'spending' // overflow = sideways, spending = downward
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowEdge = Edge<FlowEdgeData>
|
||||||
|
|
||||||
|
// Serializable space config (no xyflow internals)
|
||||||
|
export interface SpaceConfig {
|
||||||
|
name: string
|
||||||
|
nodes: FlowNode[]
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -9,16 +9,20 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
|
"lz-string": "^1.5.0",
|
||||||
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/lz-string": "^1.5.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1"
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
pnpm-lock.yaml
231
pnpm-lock.yaml
|
|
@ -8,6 +8,12 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@xyflow/react':
|
||||||
|
specifier: ^12.10.0
|
||||||
|
version: 12.10.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
lz-string:
|
||||||
|
specifier: ^1.5.0
|
||||||
|
version: 1.5.0
|
||||||
next:
|
next:
|
||||||
specifier: 14.2.35
|
specifier: 14.2.35
|
||||||
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 14.2.35(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
|
@ -17,7 +23,13 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18
|
specifier: ^18
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.11
|
||||||
|
version: 5.0.11(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/lz-string':
|
||||||
|
specifier: ^1.5.0
|
||||||
|
version: 1.5.0
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.33
|
version: 20.19.33
|
||||||
|
|
@ -131,6 +143,28 @@ packages:
|
||||||
'@swc/helpers@0.5.5':
|
'@swc/helpers@0.5.5':
|
||||||
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
|
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3':
|
||||||
|
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11':
|
||||||
|
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
|
||||||
|
|
||||||
|
'@types/lz-string@1.5.0':
|
||||||
|
resolution: {integrity: sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw==}
|
||||||
|
deprecated: This is a stub types definition. lz-string provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@20.19.33':
|
'@types/node@20.19.33':
|
||||||
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
|
||||||
|
|
||||||
|
|
@ -145,6 +179,15 @@ packages:
|
||||||
'@types/react@18.3.28':
|
'@types/react@18.3.28':
|
||||||
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==}
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.0':
|
||||||
|
resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=17'
|
||||||
|
react-dom: '>=17'
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.74':
|
||||||
|
resolution: {integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==}
|
||||||
|
|
||||||
any-promise@1.3.0:
|
any-promise@1.3.0:
|
||||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||||
|
|
||||||
|
|
@ -178,6 +221,9 @@ packages:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
|
||||||
|
classcat@5.0.5:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
client-only@0.0.1:
|
client-only@0.0.1:
|
||||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||||
|
|
||||||
|
|
@ -193,6 +239,44 @@ packages:
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
|
d3-color@3.1.0:
|
||||||
|
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1:
|
||||||
|
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-ease@3.0.1:
|
||||||
|
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-selection@3.0.0:
|
||||||
|
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-timer@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
d3-transition@3.0.1:
|
||||||
|
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
peerDependencies:
|
||||||
|
d3-selection: 2 - 3
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
|
@ -280,6 +364,10 @@ packages:
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
lz-string@1.5.0:
|
||||||
|
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
merge2@1.4.1:
|
merge2@1.4.1:
|
||||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -497,9 +585,47 @@ packages:
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0:
|
||||||
|
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
zustand@4.5.7:
|
||||||
|
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||||
|
engines: {node: '>=12.7.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=16.8'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=16.8'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
zustand@5.0.11:
|
||||||
|
resolution: {integrity: sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
@ -566,6 +692,31 @@ snapshots:
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@types/d3-color@3.1.3': {}
|
||||||
|
|
||||||
|
'@types/d3-drag@3.0.7':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-interpolate@3.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-color': 3.1.3
|
||||||
|
|
||||||
|
'@types/d3-selection@3.0.11': {}
|
||||||
|
|
||||||
|
'@types/d3-transition@3.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/d3-zoom@3.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
|
||||||
|
'@types/lz-string@1.5.0':
|
||||||
|
dependencies:
|
||||||
|
lz-string: 1.5.0
|
||||||
|
|
||||||
'@types/node@20.19.33':
|
'@types/node@20.19.33':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
@ -581,6 +732,29 @@ snapshots:
|
||||||
'@types/prop-types': 15.7.15
|
'@types/prop-types': 15.7.15
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@xyflow/react@12.10.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@xyflow/system': 0.0.74
|
||||||
|
classcat: 5.0.5
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
zustand: 4.5.7(@types/react@18.3.28)(react@18.3.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- immer
|
||||||
|
|
||||||
|
'@xyflow/system@0.0.74':
|
||||||
|
dependencies:
|
||||||
|
'@types/d3-drag': 3.0.7
|
||||||
|
'@types/d3-interpolate': 3.0.4
|
||||||
|
'@types/d3-selection': 3.0.11
|
||||||
|
'@types/d3-transition': 3.0.9
|
||||||
|
'@types/d3-zoom': 3.0.8
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-zoom: 3.0.0
|
||||||
|
|
||||||
any-promise@1.3.0: {}
|
any-promise@1.3.0: {}
|
||||||
|
|
||||||
anymatch@3.1.3:
|
anymatch@3.1.3:
|
||||||
|
|
@ -616,6 +790,8 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
classcat@5.0.5: {}
|
||||||
|
|
||||||
client-only@0.0.1: {}
|
client-only@0.0.1: {}
|
||||||
|
|
||||||
commander@4.1.1: {}
|
commander@4.1.1: {}
|
||||||
|
|
@ -624,6 +800,42 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
d3-color@3.1.0: {}
|
||||||
|
|
||||||
|
d3-dispatch@3.0.1: {}
|
||||||
|
|
||||||
|
d3-drag@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
|
||||||
|
d3-ease@3.0.1: {}
|
||||||
|
|
||||||
|
d3-interpolate@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
|
||||||
|
d3-selection@3.0.0: {}
|
||||||
|
|
||||||
|
d3-timer@3.0.1: {}
|
||||||
|
|
||||||
|
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||||
|
dependencies:
|
||||||
|
d3-color: 3.1.0
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-ease: 3.0.1
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
d3-zoom@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
d3-dispatch: 3.0.1
|
||||||
|
d3-drag: 3.0.0
|
||||||
|
d3-interpolate: 3.0.1
|
||||||
|
d3-selection: 3.0.0
|
||||||
|
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
@ -695,6 +907,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
|
|
||||||
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
micromatch@4.0.8:
|
micromatch@4.0.8:
|
||||||
|
|
@ -906,4 +1120,21 @@ snapshots:
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
use-sync-external-store@1.6.0(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
|
zustand@4.5.7(@types/react@18.3.28)(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.28
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
|
zustand@5.0.11(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.28
|
||||||
|
react: 18.3.1
|
||||||
|
use-sync-external-store: 1.6.0(react@18.3.1)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue