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:
parent
d9cc86637c
commit
92edaaed45
|
|
@ -0,0 +1,6 @@
|
||||||
|
/* Choices module theme */
|
||||||
|
body[data-theme="light"] main {
|
||||||
|
background: #0f172a;
|
||||||
|
min-height: calc(100vh - 52px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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}`);
|
||||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/* Funds module theme */
|
||||||
|
body[data-theme="light"] main {
|
||||||
|
background: #0f172a;
|
||||||
|
min-height: calc(100vh - 52px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
];
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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}`);
|
||||||
|
|
@ -44,6 +44,8 @@ import { pubsModule } from "../modules/pubs/mod";
|
||||||
import { cartModule } from "../modules/cart/mod";
|
import { cartModule } from "../modules/cart/mod";
|
||||||
import { providersModule } from "../modules/providers/mod";
|
import { providersModule } from "../modules/providers/mod";
|
||||||
import { swagModule } from "../modules/swag/mod";
|
import { swagModule } from "../modules/swag/mod";
|
||||||
|
import { choicesModule } from "../modules/choices/mod";
|
||||||
|
import { fundsModule } from "../modules/funds/mod";
|
||||||
import { spaces } from "./spaces";
|
import { spaces } from "./spaces";
|
||||||
import { renderShell } from "./shell";
|
import { renderShell } from "./shell";
|
||||||
|
|
||||||
|
|
@ -54,6 +56,8 @@ registerModule(pubsModule);
|
||||||
registerModule(cartModule);
|
registerModule(cartModule);
|
||||||
registerModule(providersModule);
|
registerModule(providersModule);
|
||||||
registerModule(swagModule);
|
registerModule(swagModule);
|
||||||
|
registerModule(choicesModule);
|
||||||
|
registerModule(fundsModule);
|
||||||
|
|
||||||
// ── Config ──
|
// ── Config ──
|
||||||
const PORT = Number(process.env.PORT) || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,67 @@ export default defineConfig({
|
||||||
resolve(__dirname, "modules/swag/components/swag.css"),
|
resolve(__dirname, "modules/swag/components/swag.css"),
|
||||||
resolve(__dirname, "dist/modules/swag/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"),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue