1044 lines
52 KiB
TypeScript
1044 lines
52 KiB
TypeScript
/**
|
|
* <folk-flow-river> — animated SVG sankey river visualization.
|
|
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
|
|
* Parent component (folk-flows-app) handles data fetching and mapping.
|
|
*
|
|
* Visual vocabulary:
|
|
* Source → tap/faucet with draggable valve handle
|
|
* Funnel → trapezoid vessel with water fill + overflow lips
|
|
* Branch → pipe from vessel lip to downstream vessel
|
|
* Outcome → pool (unchanged)
|
|
*/
|
|
|
|
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;
|
|
maxSourceFlowRate: number;
|
|
}
|
|
|
|
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; valveAngle: number; sourceType: string; }
|
|
interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; vesselWidth: number; vesselHeight: number; vesselBottomWidth: number; fillLevel: number; overflowLevel: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; leftOverflowPipes: OverflowPipe[]; rightOverflowPipes: OverflowPipe[]; }
|
|
interface OverflowPipe { targetId: string; percentage: number; lipY: number; lipX: number; side: "left" | "right"; flowAmount: number; isActive: boolean; }
|
|
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; side: "left" | "right"; isActive: boolean; flowAmount: number; }
|
|
|
|
// ─── Constants ───────────────────────────────────────────
|
|
|
|
const TAP_WIDTH = 140;
|
|
const TAP_HEIGHT = 100;
|
|
const VALVE_RADIUS = 18;
|
|
const HANDLE_LENGTH = 24;
|
|
|
|
const VESSEL_WIDTH = 200;
|
|
const VESSEL_HEIGHT = 140;
|
|
const VESSEL_BOTTOM_WIDTH = 100;
|
|
const OVERFLOW_LIP_WIDTH = 20;
|
|
const DRAIN_WIDTH = 24;
|
|
|
|
const LAYER_HEIGHT = 240;
|
|
const WATERFALL_HEIGHT = 160;
|
|
const GAP = 60;
|
|
const MIN_RIVER_WIDTH = 24;
|
|
const MAX_RIVER_WIDTH = 100;
|
|
const MIN_WATERFALL_WIDTH = 4;
|
|
const SEGMENT_LENGTH = 220;
|
|
const POOL_WIDTH = 110;
|
|
const POOL_HEIGHT = 65;
|
|
const SOURCE_HEIGHT = TAP_HEIGHT;
|
|
const MAX_PIPE_WIDTH = 20;
|
|
|
|
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",
|
|
metal: ["#64748b", "#94a3b8", "#64748b"],
|
|
water: "#38bdf8",
|
|
bg: "var(--rs-bg-page)",
|
|
surface: "var(--rs-bg-surface)",
|
|
surfaceRaised: "var(--rs-bg-surface-raised)",
|
|
text: "var(--rs-text-primary)",
|
|
textMuted: "var(--rs-text-secondary)",
|
|
sourceTypeColors: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record<string, string>,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/** Interpolate left/right edges of trapezoid vessel at a given Y fraction (0=top, 1=bottom) */
|
|
function vesselEdgesAtY(x: number, topW: number, bottomW: number, height: number, yFrac: number): { left: number; right: number } {
|
|
const halfTop = topW / 2;
|
|
const halfBottom = bottomW / 2;
|
|
const cx = x + topW / 2;
|
|
const halfAtY = halfTop + (halfBottom - halfTop) * yFrac;
|
|
return { left: cx - halfAtY, right: cx + halfAtY };
|
|
}
|
|
|
|
// ─── Layout engine ──────────────────────────────────────
|
|
|
|
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 maxSourceFlowRate = Math.max(1, ...sourceNodes.map((n) => (n.data as SourceNodeData).flowRate));
|
|
|
|
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 * VESSEL_WIDTH + (layerNodes.length - 1) * GAP * 2;
|
|
|
|
layerNodes.forEach((n, i) => {
|
|
const data = n.data as FunnelNodeData;
|
|
const inflow = data.inflowRate || 0;
|
|
const fillLevel = Math.min(1, data.currentValue / (data.maxCapacity || 1));
|
|
const overflowLevel = data.maxThreshold / (data.maxCapacity || 1);
|
|
const x = -totalWidth / 2 + i * (VESSEL_WIDTH + GAP * 2);
|
|
const status: "healthy" | "overflow" | "critical" =
|
|
data.currentValue > data.maxThreshold ? "overflow" :
|
|
data.currentValue < data.minThreshold ? "critical" : "healthy";
|
|
|
|
// Distribute overflow allocations to left/right lips
|
|
const overflows = data.overflowAllocations || [];
|
|
const leftOverflowPipes: OverflowPipe[] = [];
|
|
const rightOverflowPipes: OverflowPipe[] = [];
|
|
const lipY = layerY + VESSEL_HEIGHT * (1 - overflowLevel);
|
|
const edges = vesselEdgesAtY(x, VESSEL_WIDTH, VESSEL_BOTTOM_WIDTH, VESSEL_HEIGHT, 1 - overflowLevel);
|
|
|
|
overflows.forEach((alloc, idx) => {
|
|
const isActive = data.currentValue > data.maxThreshold;
|
|
const excess = isActive ? data.currentValue - data.maxThreshold : 0;
|
|
const flowAmt = excess * (alloc.percentage / 100);
|
|
const pipe: OverflowPipe = {
|
|
targetId: alloc.targetId,
|
|
percentage: alloc.percentage,
|
|
lipY,
|
|
lipX: idx % 2 === 0 ? edges.left : edges.right,
|
|
side: idx % 2 === 0 ? "left" : "right",
|
|
flowAmount: flowAmt,
|
|
isActive,
|
|
};
|
|
if (pipe.side === "left") leftOverflowPipes.push(pipe);
|
|
else rightOverflowPipes.push(pipe);
|
|
});
|
|
|
|
funnelLayouts.push({
|
|
id: n.id, label: data.label, data, x, y: layerY,
|
|
vesselWidth: VESSEL_WIDTH, vesselHeight: VESSEL_HEIGHT,
|
|
vesselBottomWidth: VESSEL_BOTTOM_WIDTH,
|
|
fillLevel, overflowLevel, layer, status,
|
|
sufficiency: computeSufficiencyState(data),
|
|
leftOverflowPipes, rightOverflowPipes,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Source layouts
|
|
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
|
const data = n.data as SourceNodeData;
|
|
const totalWidth = sourceNodes.length * TAP_WIDTH + (sourceNodes.length - 1) * GAP;
|
|
const valveAngle = (data.flowRate / maxSourceFlowRate) * 90;
|
|
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (TAP_WIDTH + GAP), y: sourceLayerY, width: TAP_WIDTH, valveAngle, sourceType: data.sourceType || "unconfigured" };
|
|
});
|
|
|
|
// 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 targetTopWidth = targetLayout.vesselWidth;
|
|
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetTopWidth * 0.4);
|
|
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.5);
|
|
// Target: top-center of vessel
|
|
const targetCenterX = targetLayout.x + targetTopWidth / 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: targetCenterX, 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;
|
|
const cx = layout.x + layout.vesselWidth / 2;
|
|
const w = Math.max(MIN_WATERFALL_WIDTH, layout.vesselWidth * 0.3);
|
|
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: cx, xSource: cx, yStart: GAP, yEnd: layout.y, width: w, riverEndWidth: w, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, w * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate });
|
|
});
|
|
}
|
|
|
|
// Distribute inflows side-by-side at each funnel's top (Sankey stacking)
|
|
const inflowsPerFunnel = new Map<string, WaterfallLayout[]>();
|
|
sourceWaterfalls.forEach(wf => {
|
|
if (!inflowsPerFunnel.has(wf.targetId)) inflowsPerFunnel.set(wf.targetId, []);
|
|
inflowsPerFunnel.get(wf.targetId)!.push(wf);
|
|
});
|
|
inflowsPerFunnel.forEach((wfs, targetId) => {
|
|
const target = funnelLayouts.find(f => f.id === targetId);
|
|
if (!target || wfs.length <= 1) return;
|
|
const totalW = wfs.reduce((s, w) => s + w.width, 0);
|
|
const cx = target.x + target.vesselWidth / 2;
|
|
let offset = -totalW / 2;
|
|
wfs.forEach(wf => {
|
|
wf.x = cx + offset + wf.width / 2;
|
|
offset += wf.width;
|
|
});
|
|
});
|
|
|
|
// Overflow branches — from lip positions to target vessel top
|
|
const overflowBranches: BranchLayout[] = [];
|
|
funnelNodes.forEach((n) => {
|
|
const data = n.data as FunnelNodeData;
|
|
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
|
|
if (!parentLayout) return;
|
|
const allPipes = [...parentLayout.leftOverflowPipes, ...parentLayout.rightOverflowPipes];
|
|
allPipes.forEach((pipe) => {
|
|
const childLayout = funnelLayouts.find((f) => f.id === pipe.targetId);
|
|
if (!childLayout) return;
|
|
const width = Math.max(MIN_WATERFALL_WIDTH, (pipe.percentage / 100) * MAX_PIPE_WIDTH);
|
|
const targetCx = childLayout.x + childLayout.vesselWidth / 2;
|
|
overflowBranches.push({
|
|
sourceId: n.id, targetId: pipe.targetId, percentage: pipe.percentage,
|
|
x1: pipe.lipX, y1: pipe.lipY,
|
|
x2: targetCx, y2: childLayout.y,
|
|
width, color: data.overflowAllocations?.find((a) => a.targetId === pipe.targetId)?.color || COLORS.overflowBranch,
|
|
side: pipe.side, isActive: pipe.isActive, flowAmount: pipe.flowAmount,
|
|
});
|
|
});
|
|
});
|
|
|
|
// 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 — from vessel bottom drain to outcome pools
|
|
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 drainSpan = parentLayout.vesselBottomWidth * 0.8;
|
|
const slotWidths = distributeWidths(percentages, drainSpan, MIN_WATERFALL_WIDTH);
|
|
const drainCx = parentLayout.x + parentLayout.vesselWidth / 2;
|
|
const startX = drainCx - drainSpan / 2;
|
|
let offsetX = 0;
|
|
allocations.forEach((alloc, i) => {
|
|
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
|
|
if (!outcomeLayout) return;
|
|
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, slotWidths[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 + VESSEL_HEIGHT + 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.vesselWidth),
|
|
...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 + VESSEL_HEIGHT), ...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 = 100;
|
|
|
|
const offsetXGlobal = -minX + padding;
|
|
const offsetYGlobal = padding;
|
|
|
|
funnelLayouts.forEach((f) => {
|
|
f.x += offsetXGlobal; f.y += offsetYGlobal;
|
|
f.leftOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += offsetYGlobal; });
|
|
f.rightOverflowPipes.forEach((p) => { p.lipX += offsetXGlobal; p.lipY += 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, maxSourceFlowRate };
|
|
}
|
|
|
|
// ─── SVG Rendering ──────────────────────────────────────
|
|
|
|
function renderWaterfall(wf: WaterfallLayout): string {
|
|
const isInflow = wf.direction === "inflow";
|
|
const height = wf.yEnd - wf.yStart;
|
|
if (height <= 0) return "";
|
|
|
|
// Constant width throughout — Sankey-style ribbon (no taper → no S-curve)
|
|
const topWidth = wf.width;
|
|
const bottomWidth = wf.width;
|
|
const topCx = isInflow ? wf.xSource : wf.x;
|
|
const bottomCx = isInflow ? wf.x : wf.xSource;
|
|
|
|
const tl = topCx - topWidth / 2;
|
|
const tr = topCx + topWidth / 2;
|
|
const bl = bottomCx - bottomWidth / 2;
|
|
const br = bottomCx + bottomWidth / 2;
|
|
|
|
// L-shaped paths: vertical drop + horizontal merge (inflow)
|
|
// or horizontal departure + vertical drop (outflow)
|
|
const hDisplacement = Math.abs(topCx - bottomCx);
|
|
const maxR = Math.min(height * 0.2, hDisplacement > 3 ? hDisplacement * 0.4 : height * 0.15, 15);
|
|
const r = Math.max(3, maxR);
|
|
|
|
let shapePath: string;
|
|
let spinePath: string;
|
|
let leftEdge: string;
|
|
let rightEdge: string;
|
|
|
|
if (hDisplacement < 3) {
|
|
// Source directly above river — simple tapered rectangle
|
|
shapePath = `M ${tl} ${wf.yStart} L ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} L ${tr} ${wf.yStart} Z`;
|
|
spinePath = `M ${topCx} ${wf.yStart} L ${bottomCx} ${wf.yEnd}`;
|
|
leftEdge = `M ${tl} ${wf.yStart} L ${bl} ${wf.yEnd}`;
|
|
rightEdge = `M ${tr} ${wf.yStart} L ${br} ${wf.yEnd}`;
|
|
} else if (isInflow) {
|
|
// Inflow L-shape: vertical drop → rounded corner → horizontal merge into river
|
|
const hDir = Math.sign(bl - tl); // direction of horizontal leg
|
|
|
|
shapePath = [
|
|
`M ${tl} ${wf.yStart}`,
|
|
`L ${tl} ${wf.yEnd - r}`,
|
|
`Q ${tl} ${wf.yEnd}, ${tl + hDir * r} ${wf.yEnd}`,
|
|
`L ${bl} ${wf.yEnd}`,
|
|
`L ${br} ${wf.yEnd}`,
|
|
`L ${tr + hDir * r} ${wf.yEnd}`,
|
|
`Q ${tr} ${wf.yEnd}, ${tr} ${wf.yEnd - r}`,
|
|
`L ${tr} ${wf.yStart}`,
|
|
`Z`,
|
|
].join(" ");
|
|
|
|
spinePath = [
|
|
`M ${topCx} ${wf.yStart}`,
|
|
`L ${topCx} ${wf.yEnd - r}`,
|
|
`Q ${topCx} ${wf.yEnd}, ${topCx + hDir * r} ${wf.yEnd}`,
|
|
`L ${bottomCx} ${wf.yEnd}`,
|
|
].join(" ");
|
|
|
|
leftEdge = `M ${tl} ${wf.yStart} L ${tl} ${wf.yEnd - r} Q ${tl} ${wf.yEnd}, ${tl + hDir * r} ${wf.yEnd} L ${bl} ${wf.yEnd}`;
|
|
rightEdge = `M ${tr} ${wf.yStart} L ${tr} ${wf.yEnd - r} Q ${tr} ${wf.yEnd}, ${tr + hDir * r} ${wf.yEnd} L ${br} ${wf.yEnd}`;
|
|
} else {
|
|
// Outflow inverted-L: horizontal departure from river → rounded corner → vertical drop
|
|
const hDir = Math.sign(tl - bl); // direction from destination back toward river
|
|
|
|
shapePath = [
|
|
`M ${tl} ${wf.yStart}`,
|
|
`L ${bl + hDir * r} ${wf.yStart}`,
|
|
`Q ${bl} ${wf.yStart}, ${bl} ${wf.yStart + r}`,
|
|
`L ${bl} ${wf.yEnd}`,
|
|
`L ${br} ${wf.yEnd}`,
|
|
`L ${br} ${wf.yStart + r}`,
|
|
`Q ${br} ${wf.yStart}, ${br + hDir * r} ${wf.yStart}`,
|
|
`L ${tr} ${wf.yStart}`,
|
|
`Z`,
|
|
].join(" ");
|
|
|
|
spinePath = [
|
|
`M ${topCx} ${wf.yStart}`,
|
|
`L ${bottomCx + Math.sign(topCx - bottomCx) * r} ${wf.yStart}`,
|
|
`Q ${bottomCx} ${wf.yStart}, ${bottomCx} ${wf.yStart + r}`,
|
|
`L ${bottomCx} ${wf.yEnd}`,
|
|
].join(" ");
|
|
|
|
leftEdge = `M ${tl} ${wf.yStart} L ${bl + hDir * r} ${wf.yStart} Q ${bl} ${wf.yStart}, ${bl} ${wf.yStart + r} L ${bl} ${wf.yEnd}`;
|
|
rightEdge = `M ${tr} ${wf.yStart} L ${br + hDir * r} ${wf.yStart} Q ${br} ${wf.yStart}, ${br} ${wf.yStart + r} L ${br} ${wf.yEnd}`;
|
|
}
|
|
|
|
const clipId = `sankey-clip-${wf.id}`;
|
|
const gradId = `sankey-grad-${wf.id}`;
|
|
const glowId = `sankey-glow-${wf.id}`;
|
|
const pathMinX = Math.min(tl, bl) - 5;
|
|
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
|
|
|
|
const entryCx = isInflow ? bottomCx : topCx;
|
|
const entryY = isInflow ? wf.yEnd : wf.yStart;
|
|
const entryWidth = isInflow ? bottomWidth : topWidth;
|
|
const exitCx = isInflow ? topCx : bottomCx;
|
|
const exitY = isInflow ? wf.yStart : wf.yEnd;
|
|
|
|
const labelX = isInflow ? topCx : bottomCx;
|
|
const labelY = wf.yStart + height * 0.45;
|
|
const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`;
|
|
|
|
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.9 : 0.55}"/>
|
|
<stop offset="50%" stop-color="${wf.color}" stop-opacity="0.7"/>
|
|
<stop offset="100%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.4 : 0.9}"/>
|
|
</linearGradient>
|
|
<radialGradient id="${glowId}" cx="50%" cy="50%" r="50%">
|
|
<stop offset="0%" stop-color="${wf.color}" stop-opacity="0.7"/>
|
|
<stop offset="100%" stop-color="${wf.color}" stop-opacity="0"/>
|
|
</radialGradient>
|
|
<filter id="blur-${wf.id}" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="3"/>
|
|
</filter>
|
|
</defs>
|
|
<path d="${shapePath}" fill="${wf.color}" opacity="0.06"/>
|
|
<path d="${shapePath}" fill="url(#${gradId})"/>
|
|
<g clip-path="url(#${clipId})">
|
|
${[0, 1, 2, 3, 4].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height * 0.6}" fill="${wf.color}" opacity="${0.15 + i * 0.03}" style="animation:waterFlow ${0.8 + i * 0.25}s linear infinite;animation-delay:${i * -0.15}s"/>`).join("")}
|
|
</g>
|
|
<path d="${leftEdge}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
|
|
<path d="${rightEdge}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
|
|
<path d="${spinePath}" fill="none" stroke="white" stroke-width="2" opacity="0.35" stroke-dasharray="6 12" style="animation:riverCurrent 0.8s linear infinite"/>
|
|
<path d="${spinePath}" fill="none" stroke="${wf.color}" stroke-width="3" opacity="0.2" stroke-dasharray="3 18" style="animation:riverCurrent 1.2s linear infinite;animation-delay:-0.4s"/>
|
|
<ellipse cx="${entryCx}" cy="${entryY}" rx="${entryWidth * 0.7}" ry="5" fill="url(#${glowId})" style="animation:entryPulse 1.5s ease-in-out infinite"/>
|
|
<ellipse cx="${exitCx}" cy="${exitY}" rx="${Math.max(topWidth, bottomWidth) * 0.5}" ry="4" fill="url(#${glowId})" opacity="0.6"/>
|
|
<text x="${labelX}" y="${labelY}" text-anchor="middle" style="fill:${wf.color}" font-size="9" font-weight="600" opacity="0.8">${flowLabel}/mo</text>`;
|
|
}
|
|
|
|
function renderBranch(b: BranchLayout): string {
|
|
const dx = b.x2 - b.x1;
|
|
const dy = b.y2 - b.y1;
|
|
const halfW = b.width / 2;
|
|
|
|
// Pipe exits horizontally from lip, then turns down to target (L-shape)
|
|
const dirX = Math.sign(b.x2 - b.x1);
|
|
const r = Math.min(12, Math.abs(dy) * 0.2, Math.abs(b.x2 - b.x1) * 0.3);
|
|
|
|
const spinePath = r > 2
|
|
? `M ${b.x1} ${b.y1} L ${b.x2 - dirX * r} ${b.y1} Q ${b.x2} ${b.y1}, ${b.x2} ${b.y1 + r} L ${b.x2} ${b.y2}`
|
|
: `M ${b.x1} ${b.y1} L ${b.x2} ${b.y1} L ${b.x2} ${b.y2}`;
|
|
|
|
// Outer wall (dark)
|
|
const outerStroke = `<path d="${spinePath}" fill="none" stroke="#334155" stroke-width="${b.width + 6}" stroke-linecap="round" opacity="0.5"/>`;
|
|
// Inner channel (surface)
|
|
const innerStroke = `<path d="${spinePath}" fill="none" stroke="#475569" stroke-width="${b.width + 2}" stroke-linecap="round" opacity="0.4"/>`;
|
|
// Water flow (animated, only when active)
|
|
const waterFlow = b.isActive ? `
|
|
<path d="${spinePath}" fill="none" stroke="${b.color}" stroke-width="${b.width}" stroke-linecap="round" opacity="0.6"/>
|
|
<path d="${spinePath}" fill="none" stroke="${b.color}" stroke-width="${Math.max(2, b.width - 2)}" stroke-linecap="round" opacity="0.4" stroke-dasharray="8 16" style="animation:riverCurrent 0.7s linear infinite"/>
|
|
<path d="${spinePath}" fill="none" stroke="white" stroke-width="1.5" opacity="0.2" stroke-dasharray="4 12" style="animation:riverCurrent 1s linear infinite;animation-delay:-0.3s"/>` : "";
|
|
|
|
// Midpoint for label
|
|
const midX = (b.x1 + b.x2) / 2;
|
|
const midY = (b.y1 + b.y2) / 2 - 12;
|
|
|
|
return `${outerStroke}${innerStroke}${waterFlow}
|
|
<text x="${midX}" y="${midY}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
|
|
}
|
|
|
|
function renderSource(s: SourceLayout, maxFlowRate: number): string {
|
|
const cx = s.x + s.width / 2;
|
|
const valveY = s.y + 35;
|
|
const nozzleTop = valveY + VALVE_RADIUS;
|
|
const nozzleBottom = s.y + TAP_HEIGHT;
|
|
const typeColor = COLORS.sourceTypeColors[s.sourceType] || COLORS.sourceTypeColors.unconfigured;
|
|
const flowRatio = Math.min(1, s.flowRate / maxFlowRate);
|
|
const streamWidth = 4 + flowRatio * 12;
|
|
const streamOpacity = 0.3 + flowRatio * 0.5;
|
|
|
|
const metalGradId = `metal-grad-${s.id}`;
|
|
const valveGradId = `valve-grad-${s.id}`;
|
|
|
|
return `
|
|
<defs>
|
|
<linearGradient id="${metalGradId}" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stop-color="${COLORS.metal[0]}"/>
|
|
<stop offset="50%" stop-color="${COLORS.metal[1]}"/>
|
|
<stop offset="100%" stop-color="${COLORS.metal[2]}"/>
|
|
</linearGradient>
|
|
<radialGradient id="${valveGradId}" cx="40%" cy="40%" r="50%">
|
|
<stop offset="0%" stop-color="${COLORS.metal[1]}"/>
|
|
<stop offset="70%" stop-color="${typeColor}" stop-opacity="0.6"/>
|
|
<stop offset="100%" stop-color="${COLORS.metal[0]}"/>
|
|
</radialGradient>
|
|
</defs>
|
|
<!-- Inlet pipe -->
|
|
<rect x="${cx - 8}" y="${s.y}" width="16" height="${valveY - s.y}" rx="2" fill="url(#${metalGradId})" opacity="0.9"/>
|
|
<rect x="${cx - 6}" y="${s.y}" width="12" height="${valveY - s.y}" rx="1" fill="${COLORS.metal[1]}" opacity="0.15"/>
|
|
<!-- Valve body -->
|
|
<circle cx="${cx}" cy="${valveY}" r="${VALVE_RADIUS}" fill="url(#${valveGradId})" stroke="${COLORS.metal[0]}" stroke-width="1.5"/>
|
|
<!-- Valve handle -->
|
|
<g transform="rotate(${s.valveAngle}, ${cx}, ${valveY})" data-interactive="valve" data-source-id="${s.id}" style="cursor:grab">
|
|
<line x1="${cx}" y1="${valveY}" x2="${cx}" y2="${valveY - HANDLE_LENGTH - VALVE_RADIUS}" stroke="${COLORS.metal[0]}" stroke-width="4" stroke-linecap="round"/>
|
|
<circle cx="${cx}" cy="${valveY - HANDLE_LENGTH - VALVE_RADIUS}" r="5" fill="${typeColor}" stroke="${COLORS.metal[0]}" stroke-width="1.5"/>
|
|
</g>
|
|
<!-- Nozzle (trapezoid) -->
|
|
<path d="M ${cx - 12} ${nozzleTop} L ${cx - 6} ${nozzleBottom} L ${cx + 6} ${nozzleBottom} L ${cx + 12} ${nozzleTop} Z" fill="url(#${metalGradId})" opacity="0.9"/>
|
|
<!-- Water stream -->
|
|
${flowRatio > 0.01 ? `
|
|
<line x1="${cx}" y1="${nozzleBottom}" x2="${cx}" y2="${nozzleBottom + 20}" stroke="${COLORS.water}" stroke-width="${streamWidth}" stroke-linecap="round" opacity="${streamOpacity}"/>
|
|
<line x1="${cx}" y1="${nozzleBottom + 5}" x2="${cx}" y2="${nozzleBottom + 20}" stroke="white" stroke-width="1.5" opacity="0.3"/>
|
|
${[0, 1, 2].map((i) => `<circle cx="${cx + (i - 1) * 3}" cy="${nozzleBottom + 20 + i * 6}" r="${1.5 - i * 0.3}" fill="${COLORS.water}" opacity="${0.5 - i * 0.15}"><animate attributeName="cy" from="${nozzleBottom + 14}" to="${nozzleBottom + 35}" dur="${0.6 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="${0.6 - i * 0.1}" to="0" dur="${0.6 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
|
|
<!-- Edit icon -->
|
|
<circle cx="${s.x + s.width - 14}" cy="${s.y + 14}" r="10" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1" data-interactive="edit-rate" data-source-id="${s.id}" style="cursor:pointer" opacity="0.9"/>
|
|
<text x="${s.x + s.width - 14}" y="${s.y + 18}" text-anchor="middle" font-size="11" font-weight="700" style="fill:${typeColor};pointer-events:none">$</text>
|
|
<!-- Labels -->
|
|
<text x="${cx}" y="${s.y - 6}" text-anchor="middle" style="fill:${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
|
|
<text x="${cx}" y="${nozzleBottom + 42}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10" font-weight="500">$${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 isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
|
|
const cx = f.x + f.vesselWidth / 2;
|
|
const vw = f.vesselWidth;
|
|
const vbw = f.vesselBottomWidth;
|
|
const vh = f.vesselHeight;
|
|
|
|
// Trapezoid corners
|
|
const tl = f.x;
|
|
const tr = f.x + vw;
|
|
const bl = cx - vbw / 2;
|
|
const br = cx + vbw / 2;
|
|
|
|
const vesselGradId = `vessel-grad-${f.id}`;
|
|
const waterGradId = `vessel-water-${f.id}`;
|
|
const clipId = `vessel-clip-${f.id}`;
|
|
|
|
// Threshold Y positions (from top: 0=top, vh=bottom)
|
|
const overflowFrac = 1 - f.overflowLevel;
|
|
const overflowY = f.y + overflowFrac * vh;
|
|
const minFrac = 1 - (f.data.minThreshold / (f.data.maxCapacity || 1));
|
|
const minY = f.y + minFrac * vh;
|
|
const suffFrac = 1 - ((f.data.sufficientThreshold ?? f.data.maxThreshold) / (f.data.maxCapacity || 1));
|
|
const suffY = f.y + suffFrac * vh;
|
|
|
|
// Water fill
|
|
const fillTop = f.y + (1 - f.fillLevel) * vh;
|
|
const inflow = f.data.inflowRate || 0;
|
|
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
|
|
const fundingPct = Math.round(f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)) * 100);
|
|
const underfunded = f.data.currentValue < f.data.minThreshold;
|
|
|
|
// Overflow lip positions
|
|
const overflowEdges = vesselEdgesAtY(f.x, vw, vbw, vh, overflowFrac);
|
|
|
|
// Vessel outline path
|
|
const vesselPath = `M ${tl} ${f.y} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${tr} ${f.y} Z`;
|
|
|
|
// Water fill clipped to vessel
|
|
const fillEdgesTop = vesselEdgesAtY(f.x, vw, vbw, vh, (fillTop - f.y) / vh);
|
|
const fillPathStr = `M ${fillEdgesTop.left} ${fillTop} L ${bl} ${f.y + vh} L ${br} ${f.y + vh} L ${fillEdgesTop.right} ${fillTop} Z`;
|
|
|
|
// Overflow lips (U-shaped notch cutouts)
|
|
const lipH = 12;
|
|
const hasLeftLips = f.leftOverflowPipes.length > 0;
|
|
const hasRightLips = f.rightOverflowPipes.length > 0;
|
|
|
|
// Pour animation — only when overflowing
|
|
const isOverflowing = f.data.currentValue > f.data.maxThreshold;
|
|
const excessRatio = isOverflowing ? Math.min(1, (f.data.currentValue - f.data.maxThreshold) / (f.data.maxCapacity - f.data.maxThreshold || 1)) : 0;
|
|
|
|
return `
|
|
<defs>
|
|
<linearGradient id="${vesselGradId}" x1="0" y1="0" x2="1" y2="0">
|
|
<stop offset="0%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
|
|
<stop offset="50%" stop-color="${COLORS.metal[1]}" stop-opacity="0.25"/>
|
|
<stop offset="100%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
|
|
</linearGradient>
|
|
<linearGradient id="${waterGradId}" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
|
<stop offset="100%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.5"/>
|
|
</linearGradient>
|
|
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
|
|
</defs>
|
|
${isSufficient ? `<path d="${vesselPath}" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="3" opacity="0.5" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
|
|
<!-- Vessel outline -->
|
|
<path d="${vesselPath}" fill="url(#${vesselGradId})" stroke="${COLORS.metal[0]}" stroke-width="1.5" opacity="0.8"/>
|
|
<!-- Water fill -->
|
|
${f.fillLevel > 0.01 ? `
|
|
<g clip-path="url(#${clipId})">
|
|
<path d="${fillPathStr}" fill="url(#${waterGradId})"/>
|
|
<!-- Wave surface -->
|
|
<path d="M ${fillEdgesTop.left - 2} ${fillTop} Q ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.25} ${fillTop - 3}, ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.5} ${fillTop} Q ${fillEdgesTop.left + (fillEdgesTop.right - fillEdgesTop.left) * 0.75} ${fillTop + 3}, ${fillEdgesTop.right + 2} ${fillTop}" fill="none" stroke="${colors[0]}" stroke-width="2" opacity="0.5" style="animation:waveFloat 2s ease-in-out infinite"/>
|
|
<!-- Flow animation strips -->
|
|
${[0, 1, 2].map((i) => `<rect x="${f.x}" y="${fillTop + i * 15}" width="${vw}" height="8" fill="${colors[0]}" opacity="0.08" style="animation:waterFlow ${2.5 + i * 0.4}s linear infinite;animation-delay:${i * -0.5}s"/>`).join("")}
|
|
</g>` : ""}
|
|
<!-- Threshold markers -->
|
|
${[
|
|
{ y: overflowY, label: "max", frac: overflowFrac },
|
|
{ y: suffY, label: "suff", frac: suffFrac },
|
|
{ y: minY, label: "min", frac: minFrac },
|
|
].map((t) => {
|
|
if (t.frac < 0 || t.frac > 1) return "";
|
|
const edges = vesselEdgesAtY(f.x, vw, vbw, vh, t.frac);
|
|
return `<line x1="${edges.left + 4}" y1="${t.y}" x2="${edges.right - 4}" y2="${t.y}" stroke="${COLORS.textMuted}" stroke-width="1" stroke-dasharray="4 3" opacity="0.35"/>`;
|
|
}).join("")}
|
|
<!-- Overflow lips (left) -->
|
|
${hasLeftLips ? `<path d="M ${overflowEdges.left - OVERFLOW_LIP_WIDTH} ${overflowY - lipH / 2} L ${overflowEdges.left - OVERFLOW_LIP_WIDTH} ${overflowY + lipH / 2} L ${overflowEdges.left + 2} ${overflowY + lipH / 2} L ${overflowEdges.left + 2} ${overflowY - lipH / 2} Z" fill="none" stroke="${COLORS.overflowBranch}" stroke-width="1.5" opacity="0.6"/>` : ""}
|
|
<!-- Overflow lips (right) -->
|
|
${hasRightLips ? `<path d="M ${overflowEdges.right - 2} ${overflowY - lipH / 2} L ${overflowEdges.right - 2} ${overflowY + lipH / 2} L ${overflowEdges.right + OVERFLOW_LIP_WIDTH} ${overflowY + lipH / 2} L ${overflowEdges.right + OVERFLOW_LIP_WIDTH} ${overflowY - lipH / 2} Z" fill="none" stroke="${COLORS.overflowBranch}" stroke-width="1.5" opacity="0.6"/>` : ""}
|
|
<!-- Pour animation from lips -->
|
|
${isOverflowing && hasLeftLips ? `
|
|
<line x1="${overflowEdges.left - OVERFLOW_LIP_WIDTH}" y1="${overflowY}" x2="${overflowEdges.left - OVERFLOW_LIP_WIDTH - 10}" y2="${overflowY + 15}" stroke="${COLORS.overflowBranch}" stroke-width="${3 + excessRatio * 5}" stroke-linecap="round" opacity="${0.3 + excessRatio * 0.4}"/>
|
|
${[0, 1].map((i) => `<circle cx="${overflowEdges.left - OVERFLOW_LIP_WIDTH - 8 - i * 4}" cy="${overflowY + 18 + i * 8}" r="${1.5}" fill="${COLORS.overflowBranch}" opacity="${0.4}"><animate attributeName="cy" from="${overflowY + 12}" to="${overflowY + 35}" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="0.5" to="0" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
|
|
${isOverflowing && hasRightLips ? `
|
|
<line x1="${overflowEdges.right + OVERFLOW_LIP_WIDTH}" y1="${overflowY}" x2="${overflowEdges.right + OVERFLOW_LIP_WIDTH + 10}" y2="${overflowY + 15}" stroke="${COLORS.overflowBranch}" stroke-width="${3 + excessRatio * 5}" stroke-linecap="round" opacity="${0.3 + excessRatio * 0.4}"/>
|
|
${[0, 1].map((i) => `<circle cx="${overflowEdges.right + OVERFLOW_LIP_WIDTH + 8 + i * 4}" cy="${overflowY + 18 + i * 8}" r="${1.5}" fill="${COLORS.overflowBranch}" opacity="${0.4}"><animate attributeName="cy" from="${overflowY + 12}" to="${overflowY + 35}" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/><animate attributeName="opacity" from="0.5" to="0" dur="${0.5 + i * 0.2}s" repeatCount="indefinite"/></circle>`).join("")}` : ""}
|
|
<!-- Bottom drain -->
|
|
<rect x="${cx - DRAIN_WIDTH / 2}" y="${f.y + vh - 2}" width="${DRAIN_WIDTH}" height="6" rx="2" fill="${COLORS.metal[0]}" opacity="0.6"/>
|
|
<!-- Labels -->
|
|
<text x="${cx}" y="${f.y - 14}" text-anchor="middle" style="fill:${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
|
|
<text x="${cx}" y="${f.y - 2}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">$${Math.floor(inflow).toLocaleString()} → $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${Math.round((f.data.currentValue / (f.data.minThreshold || 1)) * 100)}%)` : isSufficient ? "✨" : ""}</text>
|
|
<!-- Funding bar -->
|
|
<rect x="${f.x + 10}" y="${f.y + vh + 8}" width="${vw - 20}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/>
|
|
<rect x="${f.x + 10}" y="${f.y + vh + 8}" width="${(vw - 20) * Math.min(1, f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)))}" height="3" rx="1.5" fill="${underfunded ? colors[0] : isSufficient ? COLORS.goldenGlow : 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" style="fill:${COLORS.surface};stroke:${COLORS.surfaceRaised}"/>
|
|
<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" style="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" style="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" style="fill:${COLORS.surface};stroke:${COLORS.surfaceRaised}" stroke-width="1.5"/>
|
|
<circle cx="24" cy="24" r="18" fill="none" style="stroke:${COLORS.surfaceRaised}" 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" style="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 FolkFlowRiver extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private nodes: FlowNode[] = [];
|
|
private simulating = false;
|
|
private simTimer: ReturnType<typeof setInterval> | null = null;
|
|
private dragging = false;
|
|
private dragStartX = 0;
|
|
private dragStartY = 0;
|
|
private scrollStartX = 0;
|
|
private scrollStartY = 0;
|
|
// Valve drag state
|
|
private valveDragging = false;
|
|
private valveDragSourceId: string | null = null;
|
|
private currentLayout: RiverLayout | null = null;
|
|
// Popover state
|
|
private activePopover: HTMLElement | null = null;
|
|
private renderScheduled = false;
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
static get observedAttributes() { return ["simulate"]; }
|
|
|
|
connectedCallback() {
|
|
this.simulating = this.getAttribute("simulate") === "true";
|
|
if (this.nodes.length === 0) {
|
|
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
|
|
}
|
|
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 scheduleRender() {
|
|
if (this.renderScheduled) return;
|
|
this.renderScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
this.renderScheduled = false;
|
|
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 showAmountPopover(sourceId: string, anchorX: number, anchorY: number) {
|
|
this.closePopover();
|
|
const sourceNode = this.nodes.find((n) => n.id === sourceId);
|
|
if (!sourceNode) return;
|
|
const data = sourceNode.data as SourceNodeData;
|
|
|
|
const popover = document.createElement("div");
|
|
popover.className = "amount-popover";
|
|
popover.style.left = `${anchorX}px`;
|
|
popover.style.top = `${anchorY}px`;
|
|
popover.innerHTML = `
|
|
<label style="font-size:11px;color:var(--rs-text-secondary)">Flow Rate ($/mo)</label>
|
|
<input type="number" class="pop-rate" value="${data.flowRate}" min="0" step="100"/>
|
|
<label style="font-size:11px;color:var(--rs-text-secondary);margin-top:4px">Effective Date</label>
|
|
<input type="date" class="pop-date" value="${data.effectiveDate || ""}"/>
|
|
<div style="display:flex;gap:6px;margin-top:6px">
|
|
<button class="pop-apply">Apply</button>
|
|
<button class="pop-cancel">Cancel</button>
|
|
</div>`;
|
|
|
|
const container = this.shadow.querySelector(".container") as HTMLElement;
|
|
container.appendChild(popover);
|
|
this.activePopover = popover;
|
|
|
|
const rateInput = popover.querySelector(".pop-rate") as HTMLInputElement;
|
|
const dateInput = popover.querySelector(".pop-date") as HTMLInputElement;
|
|
rateInput.focus();
|
|
rateInput.select();
|
|
|
|
const apply = () => {
|
|
const newRate = parseFloat(rateInput.value) || 0;
|
|
const newDate = dateInput.value || undefined;
|
|
this.updateSourceFlowRate(sourceId, Math.max(0, newRate), newDate);
|
|
this.closePopover();
|
|
};
|
|
|
|
popover.querySelector(".pop-apply")!.addEventListener("click", apply);
|
|
popover.querySelector(".pop-cancel")!.addEventListener("click", () => this.closePopover());
|
|
rateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); });
|
|
dateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); });
|
|
}
|
|
|
|
private closePopover() {
|
|
if (this.activePopover) {
|
|
this.activePopover.remove();
|
|
this.activePopover = null;
|
|
}
|
|
}
|
|
|
|
private updateSourceFlowRate(sourceId: string, flowRate: number, effectiveDate?: string) {
|
|
this.nodes = this.nodes.map((n) => {
|
|
if (n.id === sourceId && n.type === "source") {
|
|
return { ...n, data: { ...n.data, flowRate, effectiveDate } as SourceNodeData };
|
|
}
|
|
return n;
|
|
});
|
|
this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate, effectiveDate }, bubbles: true }));
|
|
this.scheduleRender();
|
|
}
|
|
|
|
private render() {
|
|
const layout = computeLayout(this.nodes);
|
|
this.currentLayout = layout;
|
|
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 var(--rs-bg-surface-raised); max-height: 90vh; cursor: grab; }
|
|
.container.dragging { cursor: grabbing; user-select: none; }
|
|
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 var(--rs-bg-surface-raised); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; }
|
|
.controls button:hover { border-color: var(--rs-primary-hover); color: var(--rs-text-primary); }
|
|
.controls button.active { background: var(--rs-primary); border-color: var(--rs-primary-hover); color: #fff; }
|
|
.legend { position: absolute; bottom: 12px; left: 12px; background: var(--rs-glass-bg); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 8px 12px; font-size: 10px; color: var(--rs-text-secondary); }
|
|
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
|
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
|
.amount-popover { position: absolute; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 100; min-width: 160px; }
|
|
.amount-popover input { background: var(--rs-bg-page, #0f172a); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 4px; padding: 4px 8px; color: var(--rs-text-primary, #f1f5f9); font-size: 13px; outline: none; }
|
|
.amount-popover input:focus { border-color: var(--rs-primary, #3b82f6); }
|
|
.amount-popover button { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--rs-bg-surface-raised, #334155); background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-secondary, #94a3b8); cursor: pointer; font-size: 11px; flex: 1; }
|
|
.amount-popover button.pop-apply { background: var(--rs-primary, #3b82f6); color: #fff; border-color: var(--rs-primary, #3b82f6); }
|
|
.amount-popover button:hover { opacity: 0.85; }
|
|
@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); } }
|
|
@keyframes entryPulse { 0%, 100% { opacity: 0.4; transform: scaleX(0.8); } 50% { opacity: 0.9; transform: scaleX(1.2); } }
|
|
@keyframes pourFlow { 0% { stroke-dashoffset: 24; } 100% { stroke-dashoffset: 0; } }
|
|
</style>
|
|
<div class="container">
|
|
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
|
|
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
|
|
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
|
|
${layout.overflowBranches.map(renderBranch).join("")}
|
|
${layout.sources.map((s) => renderSource(s, layout.maxSourceFlowRate)).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 ? "⏸ Pause" : "▶ 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>`;
|
|
|
|
// Event: toggle simulation
|
|
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
|
|
this.simulating = !this.simulating;
|
|
if (this.simulating) this.startSimulation();
|
|
else this.stopSimulation();
|
|
this.render();
|
|
});
|
|
|
|
// Event delegation for interactive elements + drag-to-pan
|
|
const container = this.shadow.querySelector(".container") as HTMLElement;
|
|
const svg = this.shadow.querySelector("svg") as SVGSVGElement;
|
|
if (!container || !svg) return;
|
|
|
|
container.addEventListener("pointerdown", (e: PointerEvent) => {
|
|
const target = e.target as Element;
|
|
if (target.closest("button")) return;
|
|
if (target.closest(".amount-popover")) return;
|
|
|
|
// Check for interactive SVG elements
|
|
const interactive = target.closest("[data-interactive]") as Element | null;
|
|
if (interactive) {
|
|
const action = interactive.getAttribute("data-interactive");
|
|
const sourceId = interactive.getAttribute("data-source-id");
|
|
|
|
if (action === "valve" && sourceId) {
|
|
// Start valve drag
|
|
this.valveDragging = true;
|
|
this.valveDragSourceId = sourceId;
|
|
container.setPointerCapture(e.pointerId);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
if (action === "edit-rate" && sourceId) {
|
|
// Show amount popover
|
|
const rect = container.getBoundingClientRect();
|
|
const svgRect = svg.getBoundingClientRect();
|
|
// Position popover near click
|
|
const popX = e.clientX - rect.left + container.scrollLeft + 10;
|
|
const popY = e.clientY - rect.top + container.scrollTop + 10;
|
|
this.showAmountPopover(sourceId, popX, popY);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Close popover on click outside
|
|
this.closePopover();
|
|
|
|
// Start pan drag
|
|
this.dragging = true;
|
|
this.dragStartX = e.clientX;
|
|
this.dragStartY = e.clientY;
|
|
this.scrollStartX = container.scrollLeft;
|
|
this.scrollStartY = container.scrollTop;
|
|
container.classList.add("dragging");
|
|
container.setPointerCapture(e.pointerId);
|
|
});
|
|
|
|
container.addEventListener("pointermove", (e: PointerEvent) => {
|
|
if (this.valveDragging && this.valveDragSourceId) {
|
|
// Map pointer position to valve angle
|
|
const sourceLayout = this.currentLayout?.sources.find((s) => s.id === this.valveDragSourceId);
|
|
if (!sourceLayout) return;
|
|
const svgRect = svg.getBoundingClientRect();
|
|
const svgX = (e.clientX - svgRect.left) * (layout.width / svgRect.width);
|
|
const svgY = (e.clientY - svgRect.top) * (layout.height / svgRect.height);
|
|
const cx = sourceLayout.x + sourceLayout.width / 2;
|
|
const valveY = sourceLayout.y + 35;
|
|
const angle = Math.atan2(svgX - cx, valveY - svgY) * (180 / Math.PI);
|
|
const clampedAngle = Math.max(0, Math.min(90, angle));
|
|
const newRate = (clampedAngle / 90) * layout.maxSourceFlowRate;
|
|
// Round to nearest 100
|
|
const roundedRate = Math.round(newRate / 100) * 100;
|
|
this.nodes = this.nodes.map((n) => {
|
|
if (n.id === this.valveDragSourceId && n.type === "source") {
|
|
return { ...n, data: { ...n.data, flowRate: roundedRate } as SourceNodeData };
|
|
}
|
|
return n;
|
|
});
|
|
this.scheduleRender();
|
|
return;
|
|
}
|
|
|
|
if (!this.dragging) return;
|
|
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
|
|
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
|
|
});
|
|
|
|
container.addEventListener("pointerup", (e: PointerEvent) => {
|
|
if (this.valveDragging) {
|
|
const sourceId = this.valveDragSourceId;
|
|
this.valveDragging = false;
|
|
this.valveDragSourceId = null;
|
|
container.releasePointerCapture(e.pointerId);
|
|
// Dispatch event
|
|
if (sourceId) {
|
|
const sourceNode = this.nodes.find((n) => n.id === sourceId);
|
|
if (sourceNode) {
|
|
const data = sourceNode.data as SourceNodeData;
|
|
this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate: data.flowRate, effectiveDate: data.effectiveDate }, bubbles: true }));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.dragging = false;
|
|
container.classList.remove("dragging");
|
|
container.releasePointerCapture(e.pointerId);
|
|
});
|
|
|
|
// Auto-center on initial render
|
|
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
|
|
container.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-flow-river", FolkFlowRiver);
|