feat(rflows): overhaul river view with tap/faucet sources and trapezoid vessel funnels
Replace plain rectangles with tap/faucet SVG graphics for source nodes (draggable valve handle, metallic gradients, animated water stream) and trapezoid vessel shapes for funnel nodes (water fill, wave surface, threshold markers, overflow lips with pour animations). Overflow pipes now render as 3-layer bezier connections from vessel lips. Add amount popover with date scheduling, event delegation for interactivity, and rAF-throttled valve dragging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
befd70c72b
commit
02b9feb760
|
|
@ -2,6 +2,12 @@
|
|||
* <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";
|
||||
|
|
@ -19,26 +25,40 @@ interface RiverLayout {
|
|||
spendingWaterfalls: WaterfallLayout[];
|
||||
width: number;
|
||||
height: number;
|
||||
maxSourceFlowRate: 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; fillRatio: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; }
|
||||
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; }
|
||||
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 LAYER_HEIGHT = 180;
|
||||
const WATERFALL_HEIGHT = 140;
|
||||
const GAP = 50;
|
||||
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 = 45;
|
||||
const SOURCE_HEIGHT = TAP_HEIGHT;
|
||||
const MAX_PIPE_WIDTH = 20;
|
||||
|
||||
const COLORS = {
|
||||
sourceWaterfall: "#10b981",
|
||||
|
|
@ -50,11 +70,14 @@ const COLORS = {
|
|||
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[] {
|
||||
|
|
@ -73,13 +96,24 @@ function distributeWidths(percentages: number[], totalAvailable: number, minWidt
|
|||
return widths;
|
||||
}
|
||||
|
||||
// ─── Layout engine (faithful port) ──────────────────────
|
||||
/** 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>();
|
||||
|
||||
|
|
@ -121,37 +155,62 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
|
||||
const funnelLayouts: FunnelLayout[] = [];
|
||||
|
||||
// Find max desiredOutflow to normalize pipe widths
|
||||
const maxOutflow = Math.max(1, ...funnelNodes.map((n) => (n.data as FunnelNodeData).desiredOutflow || (n.data as FunnelNodeData).inflowRate || 1));
|
||||
|
||||
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;
|
||||
const totalWidth = layerNodes.length * VESSEL_WIDTH + (layerNodes.length - 1) * GAP * 2;
|
||||
|
||||
layerNodes.forEach((n, i) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
const outflow = data.desiredOutflow || data.inflowRate || 1;
|
||||
const inflow = data.inflowRate || 0;
|
||||
// Pipe width = desiredOutflow (what they need)
|
||||
const outflowRatio = Math.min(1, outflow / maxOutflow);
|
||||
const riverWidth = MIN_RIVER_WIDTH + outflowRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
|
||||
// Fill ratio = inflowRate / desiredOutflow (how funded they are)
|
||||
const fillRatio = Math.min(1, inflow / (outflow || 1));
|
||||
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
|
||||
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";
|
||||
|
||||
funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, fillRatio, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) });
|
||||
// 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 * 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 };
|
||||
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
|
||||
|
|
@ -177,16 +236,20 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
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 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: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount });
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -197,21 +260,31 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
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 });
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
// Overflow branches
|
||||
// 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;
|
||||
data.overflowAllocations?.forEach((alloc) => {
|
||||
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
|
||||
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, (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 });
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -224,7 +297,7 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent };
|
||||
});
|
||||
|
||||
// Spending waterfalls
|
||||
// Spending waterfalls — from vessel bottom drain to outcome pools
|
||||
const spendingWaterfalls: WaterfallLayout[] = [];
|
||||
funnelNodes.forEach((n) => {
|
||||
const data = n.data as FunnelNodeData;
|
||||
|
|
@ -233,42 +306,59 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
|||
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;
|
||||
const slotWidths = distributeWidths(percentages, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH);
|
||||
const drainCx = parentLayout.x + parentLayout.vesselWidth / 2;
|
||||
const startX = drainCx - DRAIN_WIDTH;
|
||||
let offsetX = 0;
|
||||
allocations.forEach((alloc, i) => {
|
||||
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
|
||||
if (!outcomeLayout) return;
|
||||
const riverEndWidth = riverEndWidths[i];
|
||||
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 + 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) });
|
||||
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.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 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 = 80;
|
||||
const padding = 100;
|
||||
|
||||
const offsetXGlobal = -minX + padding;
|
||||
const offsetYGlobal = padding;
|
||||
|
||||
funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; });
|
||||
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 };
|
||||
return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding, maxSourceFlowRate };
|
||||
}
|
||||
|
||||
// ─── SVG Rendering ──────────────────────────────────────
|
||||
|
|
@ -300,18 +390,13 @@ function renderWaterfall(wf: WaterfallLayout): string {
|
|||
const pathMinX = Math.min(tl, bl) - 5;
|
||||
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
|
||||
|
||||
// Center spine path for the flowing dashes
|
||||
const spineMidX = (topCx + bottomCx) / 2;
|
||||
const spinePath = `M ${topCx} ${wf.yStart} C ${topCx} ${cpY1}, ${bottomCx} ${cpY2}, ${bottomCx} ${wf.yEnd}`;
|
||||
|
||||
// Exit/entry point positions
|
||||
const exitCx = isInflow ? topCx : bottomCx;
|
||||
const exitY = isInflow ? wf.yStart : wf.yEnd;
|
||||
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;
|
||||
|
||||
// Flow amount label
|
||||
const labelX = (topCx + bottomCx) / 2;
|
||||
const labelY = wf.yStart + height * 0.45;
|
||||
const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`;
|
||||
|
|
@ -332,95 +417,209 @@ function renderWaterfall(wf: WaterfallLayout): string {
|
|||
<feGaussianBlur stdDeviation="3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Faint channel background -->
|
||||
<path d="${shapePath}" fill="${wf.color}" opacity="0.06"/>
|
||||
<!-- Main flow body -->
|
||||
<path d="${shapePath}" fill="url(#${gradId})"/>
|
||||
<!-- Animated flow strips (5 layers for denser flow) -->
|
||||
<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>
|
||||
<!-- Glowing edge lines -->
|
||||
<path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
|
||||
<path d="M ${tr} ${wf.yStart} C ${tr} ${cpY1}, ${br} ${cpY2}, ${br} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1.5" opacity="0.4"/>
|
||||
<!-- Animated flowing dashes along center spine -->
|
||||
<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"/>
|
||||
<!-- Entry glow (where flow meets the node) -->
|
||||
<ellipse cx="${entryCx}" cy="${entryY}" rx="${entryWidth * 0.7}" ry="5" fill="url(#${glowId})" style="animation:entryPulse 1.5s ease-in-out infinite"/>
|
||||
<!-- Exit glow (where flow leaves the node) -->
|
||||
<ellipse cx="${exitCx}" cy="${exitY}" rx="${Math.max(topWidth, bottomWidth) * 0.5}" ry="4" fill="url(#${glowId})" opacity="0.6"/>
|
||||
<!-- Flow amount label -->
|
||||
<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 cpx = b.x1 + dx * 0.5;
|
||||
const halfW = b.width / 2;
|
||||
const branchPath = `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`;
|
||||
const spinePath = `M ${b.x1} ${b.y1} C ${cpx} ${b.y1}, ${cpx} ${b.y2}, ${b.x2} ${b.y2}`;
|
||||
const clipId = `branch-clip-${b.sourceId}-${b.targetId}`;
|
||||
|
||||
return `
|
||||
<defs><clipPath id="${clipId}"><path d="${branchPath}"/></clipPath></defs>
|
||||
<path d="${branchPath}" fill="${b.color}" opacity="0.15"/>
|
||||
<path d="${branchPath}" fill="${b.color}" opacity="0.25"/>
|
||||
<g clip-path="url(#${clipId})">
|
||||
${[0, 1, 2].map((i) => `<rect x="${Math.min(b.x1, b.x2) - 5}" y="${Math.min(b.y1, b.y2) - halfW}" width="${Math.abs(dx) + 10}" height="${b.width}" fill="${b.color}" opacity="0.12" style="animation:waterFlow ${1.0 + i * 0.3}s linear infinite;animation-delay:${i * -0.2}s;transform-origin:center"/>`).join("")}
|
||||
</g>
|
||||
<path d="${spinePath}" fill="none" stroke="white" stroke-width="1.5" opacity="0.25" stroke-dasharray="4 10" style="animation:riverCurrent 0.9s linear infinite"/>
|
||||
<circle cx="${b.x2}" cy="${b.y2}" r="${halfW + 2}" fill="${b.color}" opacity="0.15" style="animation:entryPulse 1.5s ease-in-out infinite"/>
|
||||
<text x="${(b.x1 + b.x2) / 2}" y="${(b.y1 + b.y2) / 2 - 8}" text-anchor="middle" style="fill:${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
|
||||
// Pipe exits horizontally from lip, then curves down to target
|
||||
const exitX = b.side === "left" ? b.x1 - 40 : b.x1 + 40;
|
||||
const cp1x = exitX;
|
||||
const cp1y = b.y1;
|
||||
const cp2x = b.x2;
|
||||
const cp2y = b.y1 + (b.y2 - b.y1) * 0.3;
|
||||
|
||||
const spinePath = `M ${b.x1} ${b.y1} L ${exitX} ${b.y1} C ${cp1x} ${b.y1 + Math.abs(dy) * 0.4}, ${cp2x} ${cp2y}, ${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 + exitX + b.x2) / 3;
|
||||
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): string {
|
||||
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 `
|
||||
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" style="fill:${COLORS.surface};stroke:${COLORS.surfaceRaised}"/>
|
||||
<text x="${s.x + s.width / 2}" y="${s.y + 16}" text-anchor="middle" style="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" style="fill:${COLORS.textMuted}" font-size="9">$${s.flowRate.toLocaleString()}/mo</text>`;
|
||||
<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 gradId = `river-grad-${f.id}`;
|
||||
const flowGradId = `river-flow-${f.id}`;
|
||||
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
|
||||
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;
|
||||
|
||||
// Pipe = desiredOutflow (what they need), Flow = inflowRate (what they get)
|
||||
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
|
||||
// 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 flowHeight = Math.max(2, f.riverWidth * f.fillRatio);
|
||||
const flowY = f.y + (f.riverWidth - flowHeight) / 2; // center the flow vertically
|
||||
const fundingPct = Math.round(f.fillRatio * 100);
|
||||
const underfunded = f.fillRatio < 0.95;
|
||||
const flowColor = underfunded ? "#ef4444" : colors[0]; // red tint when underfunded
|
||||
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="${gradId}" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.12"/>
|
||||
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.12"/>
|
||||
<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="${flowGradId}" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
||||
<stop offset="50%" stop-color="${underfunded ? '#f87171' : (colors[1] || colors[0])}" stop-opacity="0.85"/>
|
||||
<stop offset="100%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
||||
<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 ? `<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"/>` : ""}
|
||||
<!-- Pipe = desiredOutflow (outer boundary shows what they need) -->
|
||||
<rect x="${f.x}" y="${f.y}" width="${f.segmentLength}" height="${f.riverWidth}" rx="4" fill="url(#${gradId})" stroke="${colors[0]}" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
<!-- Flow = inflowRate (inner fill shows what they actually receive) -->
|
||||
<rect x="${f.x}" y="${flowY}" width="${f.segmentLength}" height="${flowHeight}" rx="${Math.min(4, flowHeight / 2)}" fill="url(#${flowGradId})"/>
|
||||
${f.fillRatio > 0.02 ? [0, 1, 2].map((i) => `<rect x="${f.x}" y="${flowY + (flowHeight / 4) * i}" width="${f.segmentLength}" height="${Math.max(2, flowHeight / 4)}" fill="${flowColor}" opacity="0.1" 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" style="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" style="fill:${COLORS.textMuted}" font-size="10">$${Math.floor(inflow).toLocaleString()} → $${Math.floor(outflow).toLocaleString()}/mo ${underfunded ? `(${fundingPct}%)` : "✨"}</text>
|
||||
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/>
|
||||
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * f.fillRatio}" height="3" rx="1.5" fill="${underfunded ? flowColor : colors[0]}"/>`;
|
||||
${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 {
|
||||
|
|
@ -467,6 +666,13 @@ class FolkFlowRiver extends HTMLElement {
|
|||
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();
|
||||
|
|
@ -501,6 +707,15 @@ class FolkFlowRiver extends HTMLElement {
|
|||
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(() => {
|
||||
|
|
@ -513,8 +728,69 @@ class FolkFlowRiver extends HTMLElement {
|
|||
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 = `
|
||||
|
|
@ -530,18 +806,25 @@ class FolkFlowRiver extends HTMLElement {
|
|||
.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(renderSource).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)}
|
||||
|
|
@ -559,6 +842,7 @@ class FolkFlowRiver extends HTMLElement {
|
|||
</div>
|
||||
</div>`;
|
||||
|
||||
// Event: toggle simulation
|
||||
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
|
||||
this.simulating = !this.simulating;
|
||||
if (this.simulating) this.startSimulation();
|
||||
|
|
@ -566,11 +850,48 @@ class FolkFlowRiver extends HTMLElement {
|
|||
this.render();
|
||||
});
|
||||
|
||||
// Drag-to-pan
|
||||
// Event delegation for interactive elements + drag-to-pan
|
||||
const container = this.shadow.querySelector(".container") as HTMLElement;
|
||||
if (container) {
|
||||
const svg = this.shadow.querySelector("svg") as SVGSVGElement;
|
||||
if (!container || !svg) return;
|
||||
|
||||
container.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
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;
|
||||
|
|
@ -579,12 +900,54 @@ class FolkFlowRiver extends HTMLElement {
|
|||
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);
|
||||
|
|
@ -594,7 +957,6 @@ class FolkFlowRiver extends HTMLElement {
|
|||
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("folk-flow-river", FolkFlowRiver);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ export interface SourceNodeData {
|
|||
chainId?: number;
|
||||
safeAddress?: string;
|
||||
transakOrderId?: string;
|
||||
effectiveDate?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue