feat: add choices and funds modules — Phase 5+6

Phase 5 — Choices: Lightweight module wrapping the canvas-native
folk-choice-vote/rank/spider components. Lists choice shapes from the
space's Automerge doc, links to canvas for creation/interaction.

Phase 6 — Funds: Port of rfunds-online BudgetRiver visualization to
vanilla web component. Includes simulation engine (pure functions),
types (stripped @xyflow), demo presets, and SVG sankey river with
animated waterfalls, overflow branches, and sufficiency badges.
Flow-service API proxy for same-origin frontend calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-20 23:11:48 +00:00
parent d9cc86637c
commit 92edaaed45
13 changed files with 1307 additions and 0 deletions

View File

@ -0,0 +1,6 @@
/* Choices module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}

View File

@ -0,0 +1,126 @@
/**
* <folk-choices-dashboard> lists choice shapes (polls, rankings, spider charts)
* from the current space and links to the canvas to create/interact with them.
*/
class FolkChoicesDashboard extends HTMLElement {
private shadow: ShadowRoot;
private choices: any[] = [];
private loading = true;
private space: string;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this.space = this.getAttribute("space") || "demo";
}
connectedCallback() {
this.render();
this.loadChoices();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
}
private async loadChoices() {
this.loading = true;
this.render();
try {
const res = await fetch(`${this.getApiBase()}/api/choices`);
const data = await res.json();
this.choices = data.choices || [];
} catch (e) {
console.error("Failed to load choices:", e);
}
this.loading = false;
this.render();
}
private render() {
const typeIcons: Record<string, string> = {
"folk-choice-vote": "\u2611",
"folk-choice-rank": "\uD83D\uDCCA",
"folk-choice-spider": "\uD83D\uDD78",
};
const typeLabels: Record<string, string> = {
"folk-choice-vote": "Poll",
"folk-choice-rank": "Ranking",
"folk-choice-spider": "Spider Chart",
};
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.header h2 { margin: 0; color: #f1f5f9; font-size: 1.5rem; }
.create-btns { display: flex; gap: 0.5rem; }
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
.card:hover { border-color: #6366f1; }
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
.stat { display: inline-block; margin-right: 1rem; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
</style>
<div class="header">
<h2>\u2611 Choices</h2>
<div class="create-btns">
<a class="create-btn" href="/${this.space}/canvas" title="Open canvas to create choices">\u2795 New on Canvas</a>
</div>
</div>
<div class="info">
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
Create them there and they'll appear here for quick access.
</div>
${this.loading ? `<div class="loading">\u23F3 Loading choices...</div>` :
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
`;
}
private renderEmpty(): string {
return `<div class="empty">
<div class="empty-icon">\u2611</div>
<p>No choices in this space yet.</p>
<p>Open the <a href="/${this.space}/canvas" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
</div>`;
}
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
return `<div class="grid">
${this.choices.map((ch) => `
<a class="card" href="/${this.space}/canvas">
<div class="card-icon">${icons[ch.type] || "\u2611"}</div>
<div class="card-type">${labels[ch.type] || ch.type}</div>
<h3 class="card-title">${this.esc(ch.title)}</h3>
<div class="card-meta">
<span class="stat">${ch.optionCount} options</span>
<span class="stat">${ch.voteCount} responses</span>
</div>
</a>
`).join("")}
</div>`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);

69
modules/choices/mod.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* Choices module voting, ranking, and multi-criteria scoring tools.
*
* The folk-choice-* web components live in lib/ (shared with canvas).
* This module provides:
* - A page listing choice shapes in the current space
* - API to query choice shapes from the Automerge store
* - Links to create new choices on the canvas
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { getDocumentData } from "../../server/community-store";
const routes = new Hono();
// GET /api/choices — list choice shapes in the current space
routes.get("/api/choices", async (c) => {
const space = c.req.param("space") || c.req.query("space") || "demo";
const docData = getDocumentData(space);
if (!docData?.shapes) {
return c.json({ choices: [], total: 0 });
}
const choiceTypes = ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"];
const choices: any[] = [];
for (const [id, shape] of Object.entries(docData.shapes as Record<string, any>)) {
if (shape.forgotten) continue;
if (choiceTypes.includes(shape.type)) {
choices.push({
id,
type: shape.type,
title: shape.title || "Untitled",
mode: shape.mode,
optionCount: (shape.options || []).length,
voteCount: (shape.votes || shape.rankings || shape.scores || []).length,
createdAt: shape.createdAt,
});
}
}
return c.json({ choices, total: choices.length });
});
// GET / — choices page
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Choices | rSpace`,
moduleId: "choices",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
}));
});
export const choicesModule: RSpaceModule = {
id: "choices",
name: "rChoices",
icon: "☑",
description: "Polls, rankings, and multi-criteria scoring",
routes,
};

View File

@ -0,0 +1,45 @@
/**
* Choices standalone server independent deployment at choices.jeffemmett.com.
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { choicesModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", choicesModule.routes);
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
const ct = assetPath.endsWith(".js") ? "application/javascript" :
assetPath.endsWith(".css") ? "text/css" :
assetPath.endsWith(".html") ? "text/html" :
"application/octet-stream";
return new Response(file, { headers: { "Content-Type": ct } });
}
}
}
return app.fetch(req);
},
});
console.log(`rChoices standalone server running on http://localhost:${PORT}`);

View File

@ -0,0 +1,486 @@
/**
* <folk-budget-river> animated SVG sankey river visualization.
* Vanilla web component port of rfunds-online BudgetRiver.tsx.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation";
import { demoNodes } from "../lib/presets";
// ─── Layout types ───────────────────────────────────────
interface RiverLayout {
sources: SourceLayout[];
funnels: FunnelLayout[];
outcomes: OutcomeLayout[];
sourceWaterfalls: WaterfallLayout[];
overflowBranches: BranchLayout[];
spendingWaterfalls: WaterfallLayout[];
width: number;
height: number;
}
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; }
interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; }
interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; }
interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; }
interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; }
// ─── Constants ───────────────────────────────────────────
const LAYER_HEIGHT = 160;
const WATERFALL_HEIGHT = 120;
const GAP = 40;
const MIN_RIVER_WIDTH = 24;
const MAX_RIVER_WIDTH = 100;
const MIN_WATERFALL_WIDTH = 4;
const SEGMENT_LENGTH = 200;
const POOL_WIDTH = 100;
const POOL_HEIGHT = 60;
const SOURCE_HEIGHT = 40;
const COLORS = {
sourceWaterfall: "#10b981",
riverHealthy: ["#0ea5e9", "#06b6d4"],
riverOverflow: ["#f59e0b", "#fbbf24"],
riverCritical: ["#ef4444", "#f87171"],
riverSufficient: ["#fbbf24", "#10b981"],
overflowBranch: "#f59e0b",
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
outcomePool: "#3b82f6",
goldenGlow: "#fbbf24",
bg: "#0f172a",
text: "#e2e8f0",
textMuted: "#94a3b8",
};
function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] {
const totalPct = percentages.reduce((s, p) => s + p, 0);
if (totalPct === 0) return percentages.map(() => minWidth);
let widths = percentages.map((p) => (p / totalPct) * totalAvailable);
const belowMin = widths.filter((w) => w < minWidth);
if (belowMin.length > 0 && belowMin.length < widths.length) {
const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0);
const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0);
widths = widths.map((w) => {
if (w < minWidth) return minWidth;
return Math.max(minWidth, w - (w / aboveMinTotal) * deficit);
});
}
return widths;
}
// ─── Layout engine (faithful port) ──────────────────────
function computeLayout(nodes: FlowNode[]): RiverLayout {
const funnelNodes = nodes.filter((n) => n.type === "funnel");
const outcomeNodes = nodes.filter((n) => n.type === "outcome");
const sourceNodes = nodes.filter((n) => n.type === "source");
const overflowTargets = new Set<string>();
const spendingTargets = new Set<string>();
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId));
data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId));
});
const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id));
const funnelLayers = new Map<string, number>();
rootFunnels.forEach((n) => funnelLayers.set(n.id, 0));
const queue = [...rootFunnels];
while (queue.length > 0) {
const current = queue.shift()!;
const data = current.data as FunnelNodeData;
const parentLayer = funnelLayers.get(current.id) ?? 0;
data.overflowAllocations?.forEach((a) => {
const child = funnelNodes.find((n) => n.id === a.targetId);
if (child && !funnelLayers.has(child.id)) {
funnelLayers.set(child.id, parentLayer + 1);
queue.push(child);
}
});
}
const layerGroups = new Map<number, FlowNode[]>();
funnelNodes.forEach((n) => {
const layer = funnelLayers.get(n.id) ?? 0;
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
layerGroups.get(layer)!.push(n);
});
const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0);
const sourceLayerY = GAP;
const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP;
const funnelLayouts: FunnelLayout[] = [];
for (let layer = 0; layer <= maxLayer; layer++) {
const layerNodes = layerGroups.get(layer) || [];
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2;
layerNodes.forEach((n, i) => {
const data = n.data as FunnelNodeData;
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1));
const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
const status: "healthy" | "overflow" | "critical" =
data.currentValue > data.maxThreshold ? "overflow" :
data.currentValue < data.minThreshold ? "critical" : "healthy";
funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) });
});
}
// Source layouts
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
const data = n.data as SourceNodeData;
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP;
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 };
});
// Source waterfalls
const inflowsByFunnel = new Map<string, { sourceNodeId: string; allocIndex: number; flowAmount: number; percentage: number }[]>();
sourceNodes.forEach((sn) => {
const data = sn.data as SourceNodeData;
data.targetAllocations?.forEach((alloc, i) => {
const flowAmount = (alloc.percentage / 100) * data.flowRate;
if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []);
inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage });
});
});
const sourceWaterfalls: WaterfallLayout[] = [];
sourceNodes.forEach((sn) => {
const data = sn.data as SourceNodeData;
const sourceLayout = sourceLayouts.find((s) => s.id === sn.id);
if (!sourceLayout) return;
data.targetAllocations?.forEach((alloc, allocIdx) => {
const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
if (!targetLayout) return;
const flowAmount = (alloc.percentage / 100) * data.flowRate;
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth);
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8);
const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx);
const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
const startX = targetLayout.x + targetLayout.segmentLength * 0.15;
let offsetX = 0;
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k];
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2;
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2;
sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount });
});
});
// Implicit waterfalls for root funnels without source nodes
if (sourceNodes.length === 0) {
rootFunnels.forEach((rn) => {
const data = rn.data as FunnelNodeData;
if (data.inflowRate <= 0) return;
const layout = funnelLayouts.find((f) => f.id === rn.id);
if (!layout) return;
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate });
});
}
// Overflow branches
const overflowBranches: BranchLayout[] = [];
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
if (!parentLayout) return;
data.overflowAllocations?.forEach((alloc) => {
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
if (!childLayout) return;
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth);
overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch });
});
});
// Outcome layouts
const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT;
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP;
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => {
const data = n.data as OutcomeNodeData;
const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0;
return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent };
});
// Spending waterfalls
const spendingWaterfalls: WaterfallLayout[] = [];
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
if (!parentLayout) return;
const allocations = data.spendingAllocations || [];
if (allocations.length === 0) return;
const percentages = allocations.map((a) => a.percentage);
const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH);
const startX = parentLayout.x + parentLayout.segmentLength * 0.15;
let offsetX = 0;
allocations.forEach((alloc, i) => {
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
if (!outcomeLayout) return;
const riverEndWidth = riverEndWidths[i];
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6);
const riverCenterX = startX + offsetX + slotWidths[i] / 2;
offsetX += slotWidths[i];
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2;
spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) });
});
});
// Compute bounds and normalize
const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)];
const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY];
const minX = Math.min(...allX, -100);
const maxX = Math.max(...allX, 100);
const maxY = Math.max(...allY, 400);
const padding = 80;
const offsetXGlobal = -minX + padding;
const offsetYGlobal = padding;
funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; });
outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; });
sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; });
sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; });
spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding };
}
// ─── SVG Rendering ──────────────────────────────────────
function renderWaterfall(wf: WaterfallLayout): string {
const isInflow = wf.direction === "inflow";
const height = wf.yEnd - wf.yStart;
if (height <= 0) return "";
const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth;
const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth;
const topCx = isInflow ? wf.xSource : wf.x;
const bottomCx = isInflow ? wf.x : wf.xSource;
const cpFrac1 = isInflow ? 0.55 : 0.2;
const cpFrac2 = isInflow ? 0.75 : 0.45;
const cpY1 = wf.yStart + height * cpFrac1;
const cpY2 = wf.yStart + height * cpFrac2;
const tl = topCx - topWidth / 2;
const tr = topCx + topWidth / 2;
const bl = bottomCx - bottomWidth / 2;
const br = bottomCx + bottomWidth / 2;
const shapePath = `M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart} Z`;
const clipId = `sankey-clip-${wf.id}`;
const gradId = `sankey-grad-${wf.id}`;
const pathMinX = Math.min(tl, bl) - 5;
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
return `
<defs>
<clipPath id="${clipId}"><path d="${shapePath}"/></clipPath>
<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.85 : 0.5}"/>
<stop offset="50%" stop-color="${wf.color}" stop-opacity="0.65"/>
<stop offset="100%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.35 : 0.85}"/>
</linearGradient>
</defs>
<path d="${shapePath}" fill="${wf.color}" opacity="0.08"/>
<path d="${shapePath}" fill="url(#${gradId})"/>
<g clip-path="url(#${clipId})">
${[0, 1, 2].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height}" fill="${wf.color}" opacity="0.12" style="animation:waterFlow ${1.4 + i * 0.3}s linear infinite;animation-delay:${i * -0.4}s"/>`).join("")}
</g>
<path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1" opacity="0.3" stroke-dasharray="4 6" style="animation:riverCurrent 1s linear infinite"/>`;
}
function renderBranch(b: BranchLayout): string {
const dx = b.x2 - b.x1;
const dy = b.y2 - b.y1;
const cpx = b.x1 + dx * 0.5;
const halfW = b.width / 2;
return `
<path d="M ${b.x1} ${b.y1 - halfW} C ${cpx} ${b.y1 - halfW}, ${cpx} ${b.y2 - halfW}, ${b.x2} ${b.y2 - halfW} L ${b.x2} ${b.y2 + halfW} C ${cpx} ${b.y2 + halfW}, ${cpx} ${b.y1 + halfW}, ${b.x1} ${b.y1 + halfW} Z" fill="${b.color}" opacity="0.35"/>
<text x="${(b.x1 + b.x2) / 2}" y="${(b.y1 + b.y2) / 2 - 8}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
}
function renderSource(s: SourceLayout): string {
return `
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
<text x="${s.x + s.width / 2}" y="${s.y + 16}" text-anchor="middle" fill="${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
<text x="${s.x + s.width / 2}" y="${s.y + 30}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">$${s.flowRate.toLocaleString()}/mo</text>`;
}
function renderFunnel(f: FunnelLayout): string {
const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy;
const gradId = `river-grad-${f.id}`;
const fillRatio = f.data.currentValue / (f.data.maxCapacity || 1);
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
return `
<defs>
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.7"/>
</linearGradient>
</defs>
${isSufficient ? `<rect x="${f.x - 4}" y="${f.y - 4}" width="${f.segmentLength + 8}" height="${f.riverWidth + 8}" rx="6" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="2" opacity="0.6" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
<rect x="${f.x}" y="${f.y}" width="${f.segmentLength}" height="${f.riverWidth}" rx="4" fill="url(#${gradId})"/>
${[0, 1, 2].map((i) => `<rect x="${f.x}" y="${f.y + (f.riverWidth / 4) * i}" width="${f.segmentLength}" height="${f.riverWidth / 4}" fill="${colors[0]}" opacity="0.08" style="animation:waterFlow ${2 + i * 0.5}s linear infinite;animation-delay:${i * -0.6}s"/>`).join("")}
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 2}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">$${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "\u2728" : ""}</text>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" fill="#334155"/>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * fillRatio}" height="3" rx="1.5" fill="${colors[0]}"/>`;
}
function renderOutcome(o: OutcomeLayout): string {
const filled = (o.fillPercent / 100) * POOL_HEIGHT;
const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6";
return `
<rect x="${o.x}" y="${o.y}" width="${o.poolWidth}" height="${POOL_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="${filled}" rx="6" fill="${color}" opacity="0.4"/>
${filled > 5 ? `<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="3" rx="1.5" fill="${color}" opacity="0.6" style="animation:waveFloat 2s ease-in-out infinite"/>` : ""}
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 14}" text-anchor="middle" fill="${COLORS.text}" font-size="10" font-weight="500">${esc(o.label)}</text>
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 26}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">${Math.round(o.fillPercent)}%</text>`;
}
function renderSufficiencyBadge(score: number, x: number, y: number): string {
const pct = Math.round(score * 100);
const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444";
const circumference = 2 * Math.PI * 18;
const dashoffset = circumference * (1 - score);
return `
<g transform="translate(${x}, ${y})">
<circle cx="24" cy="24" r="22" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="#334155" stroke-width="3"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="${circumference}" stroke-dashoffset="${dashoffset}" transform="rotate(-90 24 24)" stroke-linecap="round"/>
<text x="24" y="22" text-anchor="middle" fill="${color}" font-size="11" font-weight="700">${pct}%</text>
<text x="24" y="34" text-anchor="middle" fill="${COLORS.textMuted}" font-size="7">ENOUGH</text>
</g>`;
}
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ─── Web Component ──────────────────────────────────────
class FolkBudgetRiver extends HTMLElement {
private shadow: ShadowRoot;
private nodes: FlowNode[] = [];
private simulating = false;
private simTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() { return ["simulate"]; }
connectedCallback() {
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
this.simulating = this.getAttribute("simulate") === "true";
this.render();
if (this.simulating) this.startSimulation();
}
disconnectedCallback() {
this.stopSimulation();
}
attributeChangedCallback(name: string, _: string, newVal: string) {
if (name === "simulate") {
this.simulating = newVal === "true";
if (this.simulating) this.startSimulation();
else this.stopSimulation();
}
}
setNodes(nodes: FlowNode[]) {
this.nodes = nodes;
this.render();
}
private startSimulation() {
if (this.simTimer) return;
this.simTimer = setInterval(() => {
this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG);
this.render();
}, 500);
}
private stopSimulation() {
if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; }
}
private render() {
const layout = computeLayout(this.nodes);
const score = computeSystemSufficiency(this.nodes);
this.shadow.innerHTML = `
<style>
:host { display: block; }
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid #334155; }
svg { display: block; }
.controls { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; }
.controls button { padding: 6px 12px; border-radius: 6px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 12px; }
.controls button:hover { border-color: #6366f1; color: #f1f5f9; }
.controls button.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.legend { position: absolute; bottom: 12px; left: 12px; background: rgba(15,23,42,0.9); border: 1px solid #334155; border-radius: 8px; padding: 8px 12px; font-size: 10px; color: #94a3b8; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
@keyframes waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } }
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
</style>
<div class="container">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${Math.min(layout.width, 1200)}" height="${Math.min(layout.height, 800)}">
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
${layout.overflowBranches.map(renderBranch).join("")}
${layout.sources.map(renderSource).join("")}
${layout.funnels.map(renderFunnel).join("")}
${layout.outcomes.map(renderOutcome).join("")}
${renderSufficiencyBadge(score, layout.width - 70, 10)}
</svg>
<div class="controls">
<button class="${this.simulating ? "active" : ""}" data-action="toggle-sim">${this.simulating ? "\u23F8 Pause" : "\u25B6 Simulate"}</button>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#10b981"></div> Inflow</div>
<div class="legend-item"><div class="legend-dot" style="background:#0ea5e9"></div> Healthy</div>
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div> Overflow</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div> Critical</div>
<div class="legend-item"><div class="legend-dot" style="background:#8b5cf6"></div> Spending</div>
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Sufficient</div>
</div>
</div>`;
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
this.simulating = !this.simulating;
if (this.simulating) this.startSimulation();
else this.stopSimulation();
this.render();
});
}
}
customElements.define("folk-budget-river", FolkBudgetRiver);

View File

@ -0,0 +1,6 @@
/* Funds module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 52px);
padding: 0;
}

View File

@ -0,0 +1,83 @@
/**
* Demo presets ported from rfunds-online/lib/presets.ts.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types";
export const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"];
export const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"];
export const demoNodes: FlowNode[] = [
{
id: "revenue", type: "source", position: { x: 660, y: -200 },
data: {
label: "Revenue Stream", flowRate: 5000, sourceType: "recurring",
targetAllocations: [{ targetId: "treasury", percentage: 100, color: "#10b981" }],
} as SourceNodeData,
},
{
id: "treasury", type: "funnel", position: { x: 630, y: 0 },
data: {
label: "Treasury", currentValue: 85000, minThreshold: 20000, maxThreshold: 70000,
maxCapacity: 100000, inflowRate: 1000, sufficientThreshold: 60000, dynamicOverflow: true,
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,
},
{
id: "public-goods", type: "funnel", position: { x: 170, y: 450 },
data: {
label: "Public Goods", currentValue: 45000, minThreshold: 15000, maxThreshold: 50000,
maxCapacity: 70000, inflowRate: 400, sufficientThreshold: 42000,
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, sufficientThreshold: 38000,
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, sufficientThreshold: 50000,
overflowAllocations: [],
spendingAllocations: [
{ targetId: "emergency-response", percentage: 100, color: SPENDING_COLORS[0] },
],
} as FunnelNodeData,
},
{ 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 },
];

View File

@ -0,0 +1,180 @@
/**
* Flow simulation engine pure function, no framework dependencies.
* Ported from rfunds-online/lib/simulation.ts.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types";
export interface SimulationConfig {
tickDivisor: number;
spendingRateHealthy: number;
spendingRateOverflow: number;
spendingRateCritical: number;
}
export const DEFAULT_CONFIG: SimulationConfig = {
tickDivisor: 10,
spendingRateHealthy: 0.5,
spendingRateOverflow: 0.8,
spendingRateCritical: 0.1,
};
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
const threshold = data.sufficientThreshold ?? data.maxThreshold;
if (data.currentValue >= data.maxCapacity) return "abundant";
if (data.currentValue >= threshold) return "sufficient";
return "seeking";
}
export function computeNeedWeights(
targetIds: string[],
allNodes: FlowNode[],
): Map<string, number> {
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
const needs = new Map<string, number>();
for (const tid of targetIds) {
const node = nodeMap.get(tid);
if (!node) { needs.set(tid, 0); continue; }
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const need = Math.max(0, 1 - d.currentValue / (threshold || 1));
needs.set(tid, need);
} else if (node.type === "outcome") {
const d = node.data as OutcomeNodeData;
const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1));
needs.set(tid, need);
} else {
needs.set(tid, 0);
}
}
const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0);
const weights = new Map<string, number>();
if (totalNeed === 0) {
const equal = targetIds.length > 0 ? 100 / targetIds.length : 0;
targetIds.forEach((id) => weights.set(id, equal));
} else {
needs.forEach((need, id) => {
weights.set(id, (need / totalNeed) * 100);
});
}
return weights;
}
export function computeSystemSufficiency(nodes: FlowNode[]): number {
let sum = 0;
let count = 0;
for (const node of nodes) {
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const threshold = d.sufficientThreshold ?? d.maxThreshold;
sum += Math.min(1, d.currentValue / (threshold || 1));
count++;
} else if (node.type === "outcome") {
const d = node.data as OutcomeNodeData;
sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1));
count++;
}
}
return count > 0 ? sum / count : 0;
}
export function simulateTick(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,
): FlowNode[] {
const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config;
const funnelNodes = nodes
.filter((n) => n.type === "funnel")
.sort((a, b) => a.position.y - b.position.y);
const overflowIncoming = new Map<string, number>();
const spendingIncoming = new Map<string, number>();
const updatedFunnels = new Map<string, FunnelNodeData>();
for (const node of funnelNodes) {
const src = node.data as FunnelNodeData;
const data: FunnelNodeData = { ...src };
let value = data.currentValue + data.inflowRate / tickDivisor;
value += overflowIncoming.get(node.id) ?? 0;
value = Math.min(value, data.maxCapacity);
if (value > data.maxThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.maxThreshold;
if (data.dynamicOverflow) {
const targetIds = data.overflowAllocations.map((a) => a.targetId);
const needWeights = computeNeedWeights(targetIds, nodes);
for (const alloc of data.overflowAllocations) {
const weight = needWeights.get(alloc.targetId) ?? 0;
const share = excess * (weight / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
}
} else {
for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
}
}
value = data.maxThreshold;
}
if (value > 0 && data.spendingAllocations.length > 0) {
let rateMultiplier: number;
if (value > data.maxThreshold) {
rateMultiplier = spendingRateOverflow;
} else if (value >= data.minThreshold) {
rateMultiplier = spendingRateHealthy;
} else {
rateMultiplier = spendingRateCritical;
}
let drain = (data.inflowRate / tickDivisor) * rateMultiplier;
drain = Math.min(drain, value);
value -= drain;
for (const alloc of data.spendingAllocations) {
const share = drain * (alloc.percentage / 100);
spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share);
}
}
data.currentValue = Math.max(0, value);
updatedFunnels.set(node.id, data);
}
return nodes.map((node) => {
if (node.type === "funnel" && updatedFunnels.has(node.id)) {
return { ...node, data: updatedFunnels.get(node.id)! };
}
if (node.type === "outcome") {
const data = node.data as OutcomeNodeData;
const incoming = spendingIncoming.get(node.id) ?? 0;
if (incoming <= 0) return node;
const newReceived = Math.min(
data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity,
data.fundingReceived + incoming,
);
let newStatus = data.status;
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== "blocked") {
newStatus = "completed";
} else if (newReceived > 0 && newStatus === "not-started") {
newStatus = "in-progress";
}
return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus } };
}
return node;
});
}

View File

@ -0,0 +1,78 @@
/**
* Flow types framework-agnostic (ported from rfunds-online, @xyflow removed).
*/
export interface IntegrationSource {
type: "rvote" | "safe" | "manual";
safeAddress?: string;
safeChainId?: number;
tokenAddress?: string | null;
tokenSymbol?: string;
tokenDecimals?: number;
rvoteSpaceSlug?: string;
rvoteProposalId?: string;
rvoteProposalStatus?: string;
rvoteProposalScore?: number;
lastFetchedAt?: number;
}
export interface OverflowAllocation {
targetId: string;
percentage: number;
color: string;
}
export interface SpendingAllocation {
targetId: string;
percentage: number;
color: string;
}
export interface SourceAllocation {
targetId: string;
percentage: number;
color: string;
}
export type SufficiencyState = "seeking" | "sufficient" | "abundant";
export interface FunnelNodeData {
label: string;
currentValue: number;
minThreshold: number;
maxThreshold: number;
maxCapacity: number;
inflowRate: number;
sufficientThreshold?: number;
dynamicOverflow?: boolean;
overflowAllocations: OverflowAllocation[];
spendingAllocations: SpendingAllocation[];
source?: IntegrationSource;
[key: string]: unknown;
}
export interface OutcomeNodeData {
label: string;
description?: string;
fundingReceived: number;
fundingTarget: number;
status: "not-started" | "in-progress" | "completed" | "blocked";
source?: IntegrationSource;
[key: string]: unknown;
}
export interface SourceNodeData {
label: string;
flowRate: number;
sourceType: "recurring" | "one-time" | "treasury";
targetAllocations: SourceAllocation[];
[key: string]: unknown;
}
/** Lightweight node (replaces @xyflow/react Node) */
export interface FlowNode {
id: string;
type: "funnel" | "outcome" | "source";
position: { x: number; y: number };
data: FunnelNodeData | OutcomeNodeData | SourceNodeData;
}

118
modules/funds/mod.ts Normal file
View File

@ -0,0 +1,118 @@
/**
* Funds module budget flows, river visualization, and treasury management.
*
* Proxies flow-service API calls and serves the BudgetRiver visualization.
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
const routes = new Hono();
// ─── Flow Service API proxy ─────────────────────────────
// These proxy to the payment-flow backend so the frontend
// can call them from the same origin.
routes.get("/api/flows", async (c) => {
const owner = c.req.header("X-Owner-Address") || "";
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows?owner=${encodeURIComponent(owner)}`);
return c.json(await res.json(), res.status as any);
});
routes.get("/api/flows/:flowId", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}`);
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/deposit", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/deposit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/withdraw", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/withdraw`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/activate", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/activate`, { method: "POST" });
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/pause", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/pause`, { method: "POST" });
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/funnels", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/funnels`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.post("/api/flows/:flowId/outcomes", async (c) => {
const body = await c.req.text();
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/outcomes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
return c.json(await res.json(), res.status as any);
});
routes.get("/api/flows/:flowId/transactions", async (c) => {
const res = await fetch(`${FLOW_SERVICE_URL}/api/flows/${c.req.param("flowId")}/transactions`);
return c.json(await res.json(), res.status as any);
});
// ─── Page route ─────────────────────────────────────────
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Funds | rSpace`,
moduleId: "funds",
spaceSlug,
modules: getModuleInfoList(),
theme: "light",
styles: `<link rel="stylesheet" href="/modules/funds/funds.css">`,
body: `<folk-budget-river simulate="true"></folk-budget-river>`,
scripts: `<script type="module" src="/modules/funds/folk-budget-river.js"></script>`,
}));
});
export const fundsModule: RSpaceModule = {
id: "funds",
name: "rFunds",
icon: "\uD83C\uDF0A",
description: "Budget flows, river visualization, and treasury management",
routes,
};

View File

@ -0,0 +1,45 @@
/**
* Funds standalone server independent deployment at rfunds.online.
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { fundsModule } from "./mod";
const PORT = Number(process.env.PORT) || 3000;
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const app = new Hono();
app.get("/.well-known/webauthn", (c) => {
return c.json(
{ origins: ["https://rspace.online"] },
200,
{ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600" }
);
});
app.route("/", fundsModule.routes);
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname !== "/" && !url.pathname.startsWith("/api/")) {
const assetPath = url.pathname.slice(1);
if (assetPath.includes(".")) {
const file = Bun.file(resolve(DIST_DIR, assetPath));
if (await file.exists()) {
const ct = assetPath.endsWith(".js") ? "application/javascript" :
assetPath.endsWith(".css") ? "text/css" :
assetPath.endsWith(".html") ? "text/html" :
"application/octet-stream";
return new Response(file, { headers: { "Content-Type": ct } });
}
}
}
return app.fetch(req);
},
});
console.log(`rFunds standalone server running on http://localhost:${PORT}`);

View File

@ -44,6 +44,8 @@ import { pubsModule } from "../modules/pubs/mod";
import { cartModule } from "../modules/cart/mod";
import { providersModule } from "../modules/providers/mod";
import { swagModule } from "../modules/swag/mod";
import { choicesModule } from "../modules/choices/mod";
import { fundsModule } from "../modules/funds/mod";
import { spaces } from "./spaces";
import { renderShell } from "./shell";
@ -54,6 +56,8 @@ registerModule(pubsModule);
registerModule(cartModule);
registerModule(providersModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(fundsModule);
// ── Config ──
const PORT = Number(process.env.PORT) || 3000;

View File

@ -213,6 +213,67 @@ export default defineConfig({
resolve(__dirname, "modules/swag/components/swag.css"),
resolve(__dirname, "dist/modules/swag/swag.css"),
);
// Build choices module component
await build({
configFile: false,
root: resolve(__dirname, "modules/choices/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/choices"),
lib: {
entry: resolve(__dirname, "modules/choices/components/folk-choices-dashboard.ts"),
formats: ["es"],
fileName: () => "folk-choices-dashboard.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-choices-dashboard.js",
},
},
},
});
// Copy choices CSS
mkdirSync(resolve(__dirname, "dist/modules/choices"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/choices/components/choices.css"),
resolve(__dirname, "dist/modules/choices/choices.css"),
);
// Build funds module component
await build({
configFile: false,
root: resolve(__dirname, "modules/funds/components"),
resolve: {
alias: {
"../lib/types": resolve(__dirname, "modules/funds/lib/types.ts"),
"../lib/simulation": resolve(__dirname, "modules/funds/lib/simulation.ts"),
"../lib/presets": resolve(__dirname, "modules/funds/lib/presets.ts"),
},
},
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/funds"),
lib: {
entry: resolve(__dirname, "modules/funds/components/folk-budget-river.ts"),
formats: ["es"],
fileName: () => "folk-budget-river.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-budget-river.js",
},
},
},
});
// Copy funds CSS
mkdirSync(resolve(__dirname, "dist/modules/funds"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/funds/components/funds.css"),
resolve(__dirname, "dist/modules/funds/funds.css"),
);
},
},
},