rspace-online/modules/funds/components/folk-budget-river.ts

487 lines
26 KiB
TypeScript

/**
* <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);