diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css
index b19dade5..f30e022e 100644
--- a/modules/rflows/components/flows.css
+++ b/modules/rflows/components/flows.css
@@ -10,22 +10,22 @@
/* Edge colors */
--rflows-edge-inflow: #10b981;
- --rflows-edge-spending: #34d399;
- --rflows-edge-overflow: #6ee7b7;
+ --rflows-edge-spending: #3b82f6;
+ --rflows-edge-overflow: #f59e0b;
/* Funnel zones */
--rflows-zone-drain: #ef4444;
--rflows-zone-drain-opacity: 0.08;
--rflows-zone-healthy: #0ea5e9;
- --rflows-zone-healthy-opacity: 0.06;
+ --rflows-zone-healthy-opacity: 0.10;
--rflows-zone-overflow: #f59e0b;
- --rflows-zone-overflow-opacity: 0.06;
- --rflows-fill-opacity: 0.25;
+ --rflows-zone-overflow-opacity: 0.12;
+ --rflows-fill-opacity: 0.45;
/* Funnel labels */
--rflows-label-inflow: #10b981;
- --rflows-label-spending: #34d399;
- --rflows-label-overflow: #6ee7b7;
+ --rflows-label-spending: #3b82f6;
+ --rflows-label-overflow: #f59e0b;
/* Status colors */
--rflows-status-critical: #ef4444;
@@ -885,6 +885,20 @@
/* ── Basin ripple wave ─────────────────────────────── */
.basin-ripple { opacity: 0.7; }
+/* ── Approaching overflow glow ───────────────────── */
+.approaching-glow { animation: approachingPulse 1.5s ease-in-out infinite; }
+@keyframes approachingPulse {
+ 0%, 100% { opacity: 0.15; }
+ 50% { opacity: 0.45; }
+}
+
+/* ── Approaching status badge ────────────────────── */
+.flows-status--approaching { color: #f59e0b; }
+.icp-suf-badge--approaching {
+ background: rgba(245, 158, 11, 0.15);
+ color: #f59e0b;
+}
+
/* ── Simulation speed slider ──────────────────────── */
.flows-sim-speed {
position: absolute; bottom: 50px; right: 10px; z-index: 10;
@@ -955,19 +969,19 @@
/* Edge colors */
--rflows-edge-inflow: #059669;
- --rflows-edge-spending: #047857;
- --rflows-edge-overflow: #059669;
+ --rflows-edge-spending: #2563eb;
+ --rflows-edge-overflow: #d97706;
/* Funnel zones */
--rflows-zone-drain-opacity: 0.15;
- --rflows-zone-healthy-opacity: 0.12;
- --rflows-zone-overflow-opacity: 0.12;
- --rflows-fill-opacity: 0.35;
+ --rflows-zone-healthy-opacity: 0.14;
+ --rflows-zone-overflow-opacity: 0.14;
+ --rflows-fill-opacity: 0.45;
/* Funnel labels */
--rflows-label-inflow: #047857;
- --rflows-label-spending: #047857;
- --rflows-label-overflow: #059669;
+ --rflows-label-spending: #2563eb;
+ --rflows-label-overflow: #d97706;
/* Status colors (darken for light bg) */
--rflows-status-overflow: #059669;
@@ -1007,17 +1021,17 @@
--rflows-source-rate: #047857;
--rflows-edge-inflow: #059669;
- --rflows-edge-spending: #047857;
- --rflows-edge-overflow: #059669;
+ --rflows-edge-spending: #2563eb;
+ --rflows-edge-overflow: #d97706;
--rflows-zone-drain-opacity: 0.15;
- --rflows-zone-healthy-opacity: 0.12;
- --rflows-zone-overflow-opacity: 0.12;
- --rflows-fill-opacity: 0.35;
+ --rflows-zone-healthy-opacity: 0.14;
+ --rflows-zone-overflow-opacity: 0.14;
+ --rflows-fill-opacity: 0.45;
--rflows-label-inflow: #047857;
- --rflows-label-spending: #047857;
- --rflows-label-overflow: #059669;
+ --rflows-label-spending: #2563eb;
+ --rflows-label-overflow: #d97706;
--rflows-status-overflow: #059669;
--rflows-status-thriving: #059669;
diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts
index c2c3f0d2..6fb49137 100644
--- a/modules/rflows/components/folk-flow-river.ts
+++ b/modules/rflows/components/folk-flow-river.ts
@@ -108,11 +108,14 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
};
});
- // Position funnels
+ // Position funnels — dynamic vessel width based on widest layer
const funnelStartY = sourceStartY + SOURCE_H + LAYER_GAP;
+ const maxLayerSize = Math.max(1, ...layers.map((l) => l.length));
+ const dynamicVesselW = Math.min(200, Math.max(120, 800 / maxLayerSize));
+ const dynamicVesselBW = dynamicVesselW * (VESSEL_BW / VESSEL_W);
const funnelLayouts: FunnelLayout[] = [];
layers.forEach((layer, layerIdx) => {
- const totalW = layer.length * VESSEL_W + (layer.length - 1) * H_GAP;
+ const totalW = layer.length * dynamicVesselW + (layer.length - 1) * H_GAP;
const layerY = funnelStartY + layerIdx * (VESSEL_H + LAYER_GAP);
layer.forEach((n, i) => {
const data = n.data as FunnelNodeData;
@@ -120,8 +123,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const overflowLevel = data.overflowThreshold / (data.capacity || 1);
funnelLayouts.push({
id: n.id, label: data.label, data,
- x: -totalW / 2 + i * (VESSEL_W + H_GAP), y: layerY,
- w: VESSEL_W, h: VESSEL_H, bw: VESSEL_BW,
+ x: -totalW / 2 + i * (dynamicVesselW + H_GAP), y: layerY,
+ w: dynamicVesselW, h: VESSEL_H, bw: dynamicVesselBW,
fillLevel, overflowLevel,
sufficiency: computeSufficiencyState(data),
});
@@ -240,34 +243,51 @@ function renderBand(b: BandLayout): string {
const hw = b.width / 2;
const dx = b.x2 - b.x1;
const dy = b.y2 - b.y1;
- if (dy <= 0) return "";
- // Cubic bezier ribbon — L-shaped path for horizontal displacement
- const hDisp = Math.abs(dx);
- const bendY = hDisp > 20 ? b.y1 + Math.min(dy * 0.3, 40) : b.y1 + dy * 0.4;
- const cp1y = b.y1 + dy * 0.15;
- const cp2y = b.y2 - dy * 0.15;
+ let path: string;
+ let center: string;
+ let midX: number;
+ let midY: number;
- // Left edge and right edge of ribbon
- const path = [
- `M ${b.x1 - hw} ${b.y1}`,
- `C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`,
- `L ${b.x2 + hw} ${b.y2}`,
- `C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`,
- `Z`,
- ].join(" ");
+ if (dy > 20) {
+ // Downward flows: existing cubic bezier ribbon
+ const cp1y = b.y1 + dy * 0.15;
+ const cp2y = b.y2 - dy * 0.15;
+ path = [
+ `M ${b.x1 - hw} ${b.y1}`,
+ `C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`,
+ `L ${b.x2 + hw} ${b.y2}`,
+ `C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`,
+ `Z`,
+ ].join(" ");
+ center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`;
+ midX = (b.x1 + b.x2) / 2;
+ midY = (b.y1 + b.y2) / 2;
+ } else {
+ // Upward/same-level flows: loopback arc routing outward then to target
+ const loopRadius = Math.max(80, Math.abs(dy) * 0.4 + Math.abs(dx) * 0.3);
+ // Route outward based on dx direction (or right if same x)
+ const outDir = dx >= 0 ? 1 : -1;
+ const arcX = b.x1 + outDir * loopRadius;
+ const arcY = Math.min(b.y1, b.y2) - loopRadius * 0.6;
- // Center-line for direction animation
- const center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`;
-
- // Label at midpoint
- const midX = (b.x1 + b.x2) / 2;
- const midY = (b.y1 + b.y2) / 2;
+ // Ribbon left/right edges via cubic bezier through the arc point
+ path = [
+ `M ${b.x1 - hw} ${b.y1}`,
+ `C ${arcX - hw} ${arcY}, ${arcX - hw} ${arcY}, ${b.x2 - hw} ${b.y2}`,
+ `L ${b.x2 + hw} ${b.y2}`,
+ `C ${arcX + hw} ${arcY}, ${arcX + hw} ${arcY}, ${b.x1 + hw} ${b.y1}`,
+ `Z`,
+ ].join(" ");
+ center = `M ${b.x1} ${b.y1} C ${arcX} ${arcY}, ${arcX} ${arcY}, ${b.x2} ${b.y2}`;
+ midX = arcX;
+ midY = arcY + loopRadius * 0.3;
+ }
return `
-
-
-
+
+
+
${b.label}`;
}
@@ -306,19 +326,23 @@ function renderFunnel(f: FunnelLayout): string {
const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac);
const isOverflowing = f.sufficiency === "overflowing";
- const fillColor = isOverflowing ? COLORS.overflow : COLORS.inflow;
+ const isApproaching = f.sufficiency === "approaching";
+ const fillColor = isOverflowing ? COLORS.overflow : isApproaching ? "#f59e0b" : COLORS.inflow;
// Value and drain labels
const val = f.data.currentValue;
const drain = f.data.drainRate;
+ const approachingGlow = isApproaching ? `` : "";
+
return `
+ ${approachingGlow}
${fillPath ? `` : ""}
${esc(f.label)}
- ${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo
+ ${fmtDollars(val)}
`;
}
@@ -498,6 +522,8 @@ class FolkFlowRiver extends HTMLElement {
.amount-popover button:hover { opacity: 0.85; }
.flow-dash { animation: dashFlow 1s linear infinite; }
@keyframes dashFlow { to { stroke-dashoffset: -14; } }
+ .approaching-glow { animation: approachingPulse 1.5s ease-in-out infinite; }
+ @keyframes approachingPulse { 0%,100% { opacity:0.15 } 50% { opacity:0.45 } }