Merge branch 'dev'
This commit is contained in:
commit
22092ef6c8
|
|
@ -2,6 +2,12 @@
|
||||||
* <folk-flow-river> — animated SVG sankey river visualization.
|
* <folk-flow-river> — animated SVG sankey river visualization.
|
||||||
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
|
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
|
||||||
* Parent component (folk-flows-app) handles data fetching and mapping.
|
* 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 type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
|
||||||
|
|
@ -19,26 +25,40 @@ interface RiverLayout {
|
||||||
spendingWaterfalls: WaterfallLayout[];
|
spendingWaterfalls: WaterfallLayout[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
maxSourceFlowRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: 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; riverWidth: number; fillRatio: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; }
|
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 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 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 ───────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────
|
||||||
|
|
||||||
const LAYER_HEIGHT = 180;
|
const TAP_WIDTH = 140;
|
||||||
const WATERFALL_HEIGHT = 140;
|
const TAP_HEIGHT = 100;
|
||||||
const GAP = 50;
|
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 MIN_RIVER_WIDTH = 24;
|
||||||
const MAX_RIVER_WIDTH = 100;
|
const MAX_RIVER_WIDTH = 100;
|
||||||
const MIN_WATERFALL_WIDTH = 4;
|
const MIN_WATERFALL_WIDTH = 4;
|
||||||
const SEGMENT_LENGTH = 220;
|
const SEGMENT_LENGTH = 220;
|
||||||
const POOL_WIDTH = 110;
|
const POOL_WIDTH = 110;
|
||||||
const POOL_HEIGHT = 65;
|
const POOL_HEIGHT = 65;
|
||||||
const SOURCE_HEIGHT = 45;
|
const SOURCE_HEIGHT = TAP_HEIGHT;
|
||||||
|
const MAX_PIPE_WIDTH = 20;
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
sourceWaterfall: "#10b981",
|
sourceWaterfall: "#10b981",
|
||||||
|
|
@ -50,11 +70,14 @@ const COLORS = {
|
||||||
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
|
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
|
||||||
outcomePool: "#3b82f6",
|
outcomePool: "#3b82f6",
|
||||||
goldenGlow: "#fbbf24",
|
goldenGlow: "#fbbf24",
|
||||||
|
metal: ["#64748b", "#94a3b8", "#64748b"],
|
||||||
|
water: "#38bdf8",
|
||||||
bg: "var(--rs-bg-page)",
|
bg: "var(--rs-bg-page)",
|
||||||
surface: "var(--rs-bg-surface)",
|
surface: "var(--rs-bg-surface)",
|
||||||
surfaceRaised: "var(--rs-bg-surface-raised)",
|
surfaceRaised: "var(--rs-bg-surface-raised)",
|
||||||
text: "var(--rs-text-primary)",
|
text: "var(--rs-text-primary)",
|
||||||
textMuted: "var(--rs-text-secondary)",
|
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[] {
|
function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] {
|
||||||
|
|
@ -73,13 +96,24 @@ function distributeWidths(percentages: number[], totalAvailable: number, minWidt
|
||||||
return widths;
|
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 {
|
function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
const funnelNodes = nodes.filter((n) => n.type === "funnel");
|
const funnelNodes = nodes.filter((n) => n.type === "funnel");
|
||||||
const outcomeNodes = nodes.filter((n) => n.type === "outcome");
|
const outcomeNodes = nodes.filter((n) => n.type === "outcome");
|
||||||
const sourceNodes = nodes.filter((n) => n.type === "source");
|
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 overflowTargets = new Set<string>();
|
||||||
const spendingTargets = new Set<string>();
|
const spendingTargets = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -121,37 +155,62 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
|
|
||||||
const funnelLayouts: FunnelLayout[] = [];
|
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++) {
|
for (let layer = 0; layer <= maxLayer; layer++) {
|
||||||
const layerNodes = layerGroups.get(layer) || [];
|
const layerNodes = layerGroups.get(layer) || [];
|
||||||
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
|
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) => {
|
layerNodes.forEach((n, i) => {
|
||||||
const data = n.data as FunnelNodeData;
|
const data = n.data as FunnelNodeData;
|
||||||
const outflow = data.desiredOutflow || data.inflowRate || 1;
|
|
||||||
const inflow = data.inflowRate || 0;
|
const inflow = data.inflowRate || 0;
|
||||||
// Pipe width = desiredOutflow (what they need)
|
const fillLevel = Math.min(1, data.currentValue / (data.maxCapacity || 1));
|
||||||
const outflowRatio = Math.min(1, outflow / maxOutflow);
|
const overflowLevel = data.maxThreshold / (data.maxCapacity || 1);
|
||||||
const riverWidth = MIN_RIVER_WIDTH + outflowRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
|
const x = -totalWidth / 2 + i * (VESSEL_WIDTH + GAP * 2);
|
||||||
// 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 status: "healthy" | "overflow" | "critical" =
|
const status: "healthy" | "overflow" | "critical" =
|
||||||
data.currentValue > data.maxThreshold ? "overflow" :
|
data.currentValue > data.maxThreshold ? "overflow" :
|
||||||
data.currentValue < data.minThreshold ? "critical" : "healthy";
|
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
|
// Source layouts
|
||||||
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
|
||||||
const data = n.data as SourceNodeData;
|
const data = n.data as SourceNodeData;
|
||||||
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP;
|
const totalWidth = sourceNodes.length * TAP_WIDTH + (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 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
|
// Source waterfalls
|
||||||
|
|
@ -177,16 +236,20 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
|
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
|
||||||
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
|
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
|
||||||
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
|
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
|
||||||
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth);
|
const targetTopWidth = targetLayout.vesselWidth;
|
||||||
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8);
|
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetTopWidth * 0.4);
|
||||||
const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx);
|
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.5);
|
||||||
const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
|
// Target: top-center of vessel
|
||||||
const startX = targetLayout.x + targetLayout.segmentLength * 0.15;
|
const targetCenterX = targetLayout.x + targetTopWidth / 2;
|
||||||
let offsetX = 0;
|
|
||||||
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k];
|
|
||||||
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2;
|
|
||||||
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2;
|
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;
|
if (data.inflowRate <= 0) return;
|
||||||
const layout = funnelLayouts.find((f) => f.id === rn.id);
|
const layout = funnelLayouts.find((f) => f.id === rn.id);
|
||||||
if (!layout) return;
|
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[] = [];
|
const overflowBranches: BranchLayout[] = [];
|
||||||
funnelNodes.forEach((n) => {
|
funnelNodes.forEach((n) => {
|
||||||
const data = n.data as FunnelNodeData;
|
const data = n.data as FunnelNodeData;
|
||||||
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
|
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
|
||||||
if (!parentLayout) return;
|
if (!parentLayout) return;
|
||||||
data.overflowAllocations?.forEach((alloc) => {
|
const allPipes = [...parentLayout.leftOverflowPipes, ...parentLayout.rightOverflowPipes];
|
||||||
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
|
allPipes.forEach((pipe) => {
|
||||||
|
const childLayout = funnelLayouts.find((f) => f.id === pipe.targetId);
|
||||||
if (!childLayout) return;
|
if (!childLayout) return;
|
||||||
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth);
|
const width = Math.max(MIN_WATERFALL_WIDTH, (pipe.percentage / 100) * MAX_PIPE_WIDTH);
|
||||||
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 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 };
|
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[] = [];
|
const spendingWaterfalls: WaterfallLayout[] = [];
|
||||||
funnelNodes.forEach((n) => {
|
funnelNodes.forEach((n) => {
|
||||||
const data = n.data as FunnelNodeData;
|
const data = n.data as FunnelNodeData;
|
||||||
|
|
@ -233,42 +306,59 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
|
||||||
const allocations = data.spendingAllocations || [];
|
const allocations = data.spendingAllocations || [];
|
||||||
if (allocations.length === 0) return;
|
if (allocations.length === 0) return;
|
||||||
const percentages = allocations.map((a) => a.percentage);
|
const percentages = allocations.map((a) => a.percentage);
|
||||||
const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
|
const slotWidths = distributeWidths(percentages, DRAIN_WIDTH * 2, MIN_WATERFALL_WIDTH);
|
||||||
const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH);
|
const drainCx = parentLayout.x + parentLayout.vesselWidth / 2;
|
||||||
const startX = parentLayout.x + parentLayout.segmentLength * 0.15;
|
const startX = drainCx - DRAIN_WIDTH;
|
||||||
let offsetX = 0;
|
let offsetX = 0;
|
||||||
allocations.forEach((alloc, i) => {
|
allocations.forEach((alloc, i) => {
|
||||||
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
|
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
|
||||||
if (!outcomeLayout) return;
|
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 farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6);
|
||||||
const riverCenterX = startX + offsetX + slotWidths[i] / 2;
|
const riverCenterX = startX + offsetX + slotWidths[i] / 2;
|
||||||
offsetX += slotWidths[i];
|
offsetX += slotWidths[i];
|
||||||
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2;
|
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
|
// 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 allX = [
|
||||||
const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY];
|
...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 minX = Math.min(...allX, -100);
|
||||||
const maxX = Math.max(...allX, 100);
|
const maxX = Math.max(...allX, 100);
|
||||||
const maxY = Math.max(...allY, 400);
|
const maxY = Math.max(...allY, 400);
|
||||||
const padding = 80;
|
const padding = 100;
|
||||||
|
|
||||||
const offsetXGlobal = -minX + padding;
|
const offsetXGlobal = -minX + padding;
|
||||||
const offsetYGlobal = 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; });
|
outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; });
|
||||||
sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.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; });
|
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; });
|
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; });
|
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 ──────────────────────────────────────
|
// ─── SVG Rendering ──────────────────────────────────────
|
||||||
|
|
@ -300,18 +390,13 @@ function renderWaterfall(wf: WaterfallLayout): string {
|
||||||
const pathMinX = Math.min(tl, bl) - 5;
|
const pathMinX = Math.min(tl, bl) - 5;
|
||||||
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
|
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}`;
|
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 entryCx = isInflow ? bottomCx : topCx;
|
||||||
const entryY = isInflow ? wf.yEnd : wf.yStart;
|
const entryY = isInflow ? wf.yEnd : wf.yStart;
|
||||||
const entryWidth = isInflow ? bottomWidth : topWidth;
|
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 labelX = (topCx + bottomCx) / 2;
|
||||||
const labelY = wf.yStart + height * 0.45;
|
const labelY = wf.yStart + height * 0.45;
|
||||||
const flowLabel = wf.flowAmount >= 1000 ? `$${(wf.flowAmount / 1000).toFixed(1)}k` : `$${Math.floor(wf.flowAmount)}`;
|
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"/>
|
<feGaussianBlur stdDeviation="3"/>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<!-- Faint channel background -->
|
|
||||||
<path d="${shapePath}" fill="${wf.color}" opacity="0.06"/>
|
<path d="${shapePath}" fill="${wf.color}" opacity="0.06"/>
|
||||||
<!-- Main flow body -->
|
|
||||||
<path d="${shapePath}" fill="url(#${gradId})"/>
|
<path d="${shapePath}" fill="url(#${gradId})"/>
|
||||||
<!-- Animated flow strips (5 layers for denser flow) -->
|
|
||||||
<g clip-path="url(#${clipId})">
|
<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("")}
|
${[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>
|
</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 ${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"/>
|
<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="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"/>
|
<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"/>
|
<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"/>
|
<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>`;
|
<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 {
|
function renderBranch(b: BranchLayout): string {
|
||||||
const dx = b.x2 - b.x1;
|
const dx = b.x2 - b.x1;
|
||||||
const dy = b.y2 - b.y1;
|
const dy = b.y2 - b.y1;
|
||||||
const cpx = b.x1 + dx * 0.5;
|
|
||||||
const halfW = b.width / 2;
|
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 `
|
// Pipe exits horizontally from lip, then curves down to target
|
||||||
<defs><clipPath id="${clipId}"><path d="${branchPath}"/></clipPath></defs>
|
const exitX = b.side === "left" ? b.x1 - 40 : b.x1 + 40;
|
||||||
<path d="${branchPath}" fill="${b.color}" opacity="0.15"/>
|
const cp1x = exitX;
|
||||||
<path d="${branchPath}" fill="${b.color}" opacity="0.25"/>
|
const cp1y = b.y1;
|
||||||
<g clip-path="url(#${clipId})">
|
const cp2x = b.x2;
|
||||||
${[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("")}
|
const cp2y = b.y1 + (b.y2 - b.y1) * 0.3;
|
||||||
</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"/>
|
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}`;
|
||||||
<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>`;
|
// 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 `
|
return `
|
||||||
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" style="fill:${COLORS.surface};stroke:${COLORS.surfaceRaised}"/>
|
<defs>
|
||||||
<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>
|
<linearGradient id="${metalGradId}" x1="0" y1="0" x2="1" y2="0">
|
||||||
<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>`;
|
<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 {
|
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 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 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)
|
// Trapezoid corners
|
||||||
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
|
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 inflow = f.data.inflowRate || 0;
|
||||||
const flowHeight = Math.max(2, f.riverWidth * f.fillRatio);
|
const outflow = f.data.desiredOutflow || f.data.inflowRate || 1;
|
||||||
const flowY = f.y + (f.riverWidth - flowHeight) / 2; // center the flow vertically
|
const fundingPct = Math.round(f.fillLevel * (f.data.maxCapacity / (f.data.maxThreshold || 1)) * 100);
|
||||||
const fundingPct = Math.round(f.fillRatio * 100);
|
const underfunded = f.data.currentValue < f.data.minThreshold;
|
||||||
const underfunded = f.fillRatio < 0.95;
|
|
||||||
const flowColor = underfunded ? "#ef4444" : colors[0]; // red tint when underfunded
|
// 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 `
|
return `
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="${vesselGradId}" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.12"/>
|
<stop offset="0%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
|
||||||
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.18"/>
|
<stop offset="50%" stop-color="${COLORS.metal[1]}" stop-opacity="0.25"/>
|
||||||
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.12"/>
|
<stop offset="100%" stop-color="${COLORS.metal[0]}" stop-opacity="0.4"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="${flowGradId}" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="${waterGradId}" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
|
||||||
<stop offset="50%" stop-color="${underfunded ? '#f87171' : (colors[1] || colors[0])}" stop-opacity="0.85"/>
|
<stop offset="100%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.5"/>
|
||||||
<stop offset="100%" stop-color="${flowColor}" stop-opacity="0.6"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
|
||||||
</defs>
|
</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"/>` : ""}
|
${isSufficient ? `<path d="${vesselPath}" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="3" opacity="0.5" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
|
||||||
<!-- Pipe = desiredOutflow (outer boundary shows what they need) -->
|
<!-- Vessel outline -->
|
||||||
<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"/>
|
<path d="${vesselPath}" fill="url(#${vesselGradId})" stroke="${COLORS.metal[0]}" stroke-width="1.5" opacity="0.8"/>
|
||||||
<!-- Flow = inflowRate (inner fill shows what they actually receive) -->
|
<!-- Water fill -->
|
||||||
<rect x="${f.x}" y="${flowY}" width="${f.segmentLength}" height="${flowHeight}" rx="${Math.min(4, flowHeight / 2)}" fill="url(#${flowGradId})"/>
|
${f.fillLevel > 0.01 ? `
|
||||||
${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("") : ""}
|
<g clip-path="url(#${clipId})">
|
||||||
<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>
|
<path d="${fillPathStr}" fill="url(#${waterGradId})"/>
|
||||||
<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>
|
<!-- Wave surface -->
|
||||||
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" style="fill:${COLORS.surfaceRaised}"/>
|
<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"/>
|
||||||
<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]}"/>`;
|
<!-- 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 {
|
function renderOutcome(o: OutcomeLayout): string {
|
||||||
|
|
@ -467,6 +666,13 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
private dragStartY = 0;
|
private dragStartY = 0;
|
||||||
private scrollStartX = 0;
|
private scrollStartX = 0;
|
||||||
private scrollStartY = 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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -501,6 +707,15 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleRender() {
|
||||||
|
if (this.renderScheduled) return;
|
||||||
|
this.renderScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.renderScheduled = false;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private startSimulation() {
|
private startSimulation() {
|
||||||
if (this.simTimer) return;
|
if (this.simTimer) return;
|
||||||
this.simTimer = setInterval(() => {
|
this.simTimer = setInterval(() => {
|
||||||
|
|
@ -513,8 +728,69 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; }
|
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() {
|
private render() {
|
||||||
const layout = computeLayout(this.nodes);
|
const layout = computeLayout(this.nodes);
|
||||||
|
this.currentLayout = layout;
|
||||||
const score = computeSystemSufficiency(this.nodes);
|
const score = computeSystemSufficiency(this.nodes);
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
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 { 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-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
|
||||||
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
|
.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 waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } }
|
||||||
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
|
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
|
||||||
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
|
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
|
||||||
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
|
@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 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>
|
</style>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
|
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
|
||||||
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
|
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
|
||||||
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
|
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
|
||||||
${layout.overflowBranches.map(renderBranch).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.funnels.map(renderFunnel).join("")}
|
||||||
${layout.outcomes.map(renderOutcome).join("")}
|
${layout.outcomes.map(renderOutcome).join("")}
|
||||||
${renderSufficiencyBadge(score, layout.width - 70, 10)}
|
${renderSufficiencyBadge(score, layout.width - 70, 10)}
|
||||||
|
|
@ -559,6 +842,7 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Event: toggle simulation
|
||||||
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
|
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
|
||||||
this.simulating = !this.simulating;
|
this.simulating = !this.simulating;
|
||||||
if (this.simulating) this.startSimulation();
|
if (this.simulating) this.startSimulation();
|
||||||
|
|
@ -566,11 +850,48 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
this.render();
|
this.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-to-pan
|
// Event delegation for interactive elements + drag-to-pan
|
||||||
const container = this.shadow.querySelector(".container") as HTMLElement;
|
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) => {
|
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.dragging = true;
|
||||||
this.dragStartX = e.clientX;
|
this.dragStartX = e.clientX;
|
||||||
this.dragStartY = e.clientY;
|
this.dragStartY = e.clientY;
|
||||||
|
|
@ -579,12 +900,54 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
container.classList.add("dragging");
|
container.classList.add("dragging");
|
||||||
container.setPointerCapture(e.pointerId);
|
container.setPointerCapture(e.pointerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.addEventListener("pointermove", (e: PointerEvent) => {
|
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;
|
if (!this.dragging) return;
|
||||||
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
|
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
|
||||||
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
|
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.addEventListener("pointerup", (e: PointerEvent) => {
|
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;
|
this.dragging = false;
|
||||||
container.classList.remove("dragging");
|
container.classList.remove("dragging");
|
||||||
container.releasePointerCapture(e.pointerId);
|
container.releasePointerCapture(e.pointerId);
|
||||||
|
|
@ -595,6 +958,5 @@ class FolkFlowRiver extends HTMLElement {
|
||||||
container.scrollTop = 0;
|
container.scrollTop = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("folk-flow-river", FolkFlowRiver);
|
customElements.define("folk-flow-river", FolkFlowRiver);
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export interface SourceNodeData {
|
||||||
chainId?: number;
|
chainId?: number;
|
||||||
safeAddress?: string;
|
safeAddress?: string;
|
||||||
transakOrderId?: string;
|
transakOrderId?: string;
|
||||||
|
effectiveDate?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue