feat(rflows): improve flow visualization with distinct edge colors and overflow glow

Differentiate spending (blue) and overflow (amber) edges from inflow (green),
increase fill opacity, add approaching-overflow pulse animation and status badge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 08:03:00 -04:00
parent c3457cf98f
commit 590cb67e02
6 changed files with 294 additions and 153 deletions

View File

@ -10,22 +10,22 @@
/* Edge colors */ /* Edge colors */
--rflows-edge-inflow: #10b981; --rflows-edge-inflow: #10b981;
--rflows-edge-spending: #34d399; --rflows-edge-spending: #3b82f6;
--rflows-edge-overflow: #6ee7b7; --rflows-edge-overflow: #f59e0b;
/* Funnel zones */ /* Funnel zones */
--rflows-zone-drain: #ef4444; --rflows-zone-drain: #ef4444;
--rflows-zone-drain-opacity: 0.08; --rflows-zone-drain-opacity: 0.08;
--rflows-zone-healthy: #0ea5e9; --rflows-zone-healthy: #0ea5e9;
--rflows-zone-healthy-opacity: 0.06; --rflows-zone-healthy-opacity: 0.10;
--rflows-zone-overflow: #f59e0b; --rflows-zone-overflow: #f59e0b;
--rflows-zone-overflow-opacity: 0.06; --rflows-zone-overflow-opacity: 0.12;
--rflows-fill-opacity: 0.25; --rflows-fill-opacity: 0.45;
/* Funnel labels */ /* Funnel labels */
--rflows-label-inflow: #10b981; --rflows-label-inflow: #10b981;
--rflows-label-spending: #34d399; --rflows-label-spending: #3b82f6;
--rflows-label-overflow: #6ee7b7; --rflows-label-overflow: #f59e0b;
/* Status colors */ /* Status colors */
--rflows-status-critical: #ef4444; --rflows-status-critical: #ef4444;
@ -885,6 +885,20 @@
/* ── Basin ripple wave ─────────────────────────────── */ /* ── Basin ripple wave ─────────────────────────────── */
.basin-ripple { opacity: 0.7; } .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 ──────────────────────── */ /* ── Simulation speed slider ──────────────────────── */
.flows-sim-speed { .flows-sim-speed {
position: absolute; bottom: 50px; right: 10px; z-index: 10; position: absolute; bottom: 50px; right: 10px; z-index: 10;
@ -955,19 +969,19 @@
/* Edge colors */ /* Edge colors */
--rflows-edge-inflow: #059669; --rflows-edge-inflow: #059669;
--rflows-edge-spending: #047857; --rflows-edge-spending: #2563eb;
--rflows-edge-overflow: #059669; --rflows-edge-overflow: #d97706;
/* Funnel zones */ /* Funnel zones */
--rflows-zone-drain-opacity: 0.15; --rflows-zone-drain-opacity: 0.15;
--rflows-zone-healthy-opacity: 0.12; --rflows-zone-healthy-opacity: 0.14;
--rflows-zone-overflow-opacity: 0.12; --rflows-zone-overflow-opacity: 0.14;
--rflows-fill-opacity: 0.35; --rflows-fill-opacity: 0.45;
/* Funnel labels */ /* Funnel labels */
--rflows-label-inflow: #047857; --rflows-label-inflow: #047857;
--rflows-label-spending: #047857; --rflows-label-spending: #2563eb;
--rflows-label-overflow: #059669; --rflows-label-overflow: #d97706;
/* Status colors (darken for light bg) */ /* Status colors (darken for light bg) */
--rflows-status-overflow: #059669; --rflows-status-overflow: #059669;
@ -1007,17 +1021,17 @@
--rflows-source-rate: #047857; --rflows-source-rate: #047857;
--rflows-edge-inflow: #059669; --rflows-edge-inflow: #059669;
--rflows-edge-spending: #047857; --rflows-edge-spending: #2563eb;
--rflows-edge-overflow: #059669; --rflows-edge-overflow: #d97706;
--rflows-zone-drain-opacity: 0.15; --rflows-zone-drain-opacity: 0.15;
--rflows-zone-healthy-opacity: 0.12; --rflows-zone-healthy-opacity: 0.14;
--rflows-zone-overflow-opacity: 0.12; --rflows-zone-overflow-opacity: 0.14;
--rflows-fill-opacity: 0.35; --rflows-fill-opacity: 0.45;
--rflows-label-inflow: #047857; --rflows-label-inflow: #047857;
--rflows-label-spending: #047857; --rflows-label-spending: #2563eb;
--rflows-label-overflow: #059669; --rflows-label-overflow: #d97706;
--rflows-status-overflow: #059669; --rflows-status-overflow: #059669;
--rflows-status-thriving: #059669; --rflows-status-thriving: #059669;

View File

@ -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 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[] = []; const funnelLayouts: FunnelLayout[] = [];
layers.forEach((layer, layerIdx) => { 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); const layerY = funnelStartY + layerIdx * (VESSEL_H + LAYER_GAP);
layer.forEach((n, i) => { layer.forEach((n, i) => {
const data = n.data as FunnelNodeData; const data = n.data as FunnelNodeData;
@ -120,8 +123,8 @@ function computeLayout(nodes: FlowNode[]): RiverLayout {
const overflowLevel = data.overflowThreshold / (data.capacity || 1); const overflowLevel = data.overflowThreshold / (data.capacity || 1);
funnelLayouts.push({ funnelLayouts.push({
id: n.id, label: data.label, data, id: n.id, label: data.label, data,
x: -totalW / 2 + i * (VESSEL_W + H_GAP), y: layerY, x: -totalW / 2 + i * (dynamicVesselW + H_GAP), y: layerY,
w: VESSEL_W, h: VESSEL_H, bw: VESSEL_BW, w: dynamicVesselW, h: VESSEL_H, bw: dynamicVesselBW,
fillLevel, overflowLevel, fillLevel, overflowLevel,
sufficiency: computeSufficiencyState(data), sufficiency: computeSufficiencyState(data),
}); });
@ -240,34 +243,51 @@ function renderBand(b: BandLayout): string {
const hw = b.width / 2; const hw = b.width / 2;
const dx = b.x2 - b.x1; const dx = b.x2 - b.x1;
const dy = b.y2 - b.y1; const dy = b.y2 - b.y1;
if (dy <= 0) return "";
// Cubic bezier ribbon — L-shaped path for horizontal displacement let path: string;
const hDisp = Math.abs(dx); let center: string;
const bendY = hDisp > 20 ? b.y1 + Math.min(dy * 0.3, 40) : b.y1 + dy * 0.4; let midX: number;
const cp1y = b.y1 + dy * 0.15; let midY: number;
const cp2y = b.y2 - dy * 0.15;
// Left edge and right edge of ribbon if (dy > 20) {
const path = [ // Downward flows: existing cubic bezier ribbon
`M ${b.x1 - hw} ${b.y1}`, const cp1y = b.y1 + dy * 0.15;
`C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`, const cp2y = b.y2 - dy * 0.15;
`L ${b.x2 + hw} ${b.y2}`, path = [
`C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`, `M ${b.x1 - hw} ${b.y1}`,
`Z`, `C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`,
].join(" "); `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 // Ribbon left/right edges via cubic bezier through the arc point
const center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`; path = [
`M ${b.x1 - hw} ${b.y1}`,
// Label at midpoint `C ${arcX - hw} ${arcY}, ${arcX - hw} ${arcY}, ${b.x2 - hw} ${b.y2}`,
const midX = (b.x1 + b.x2) / 2; `L ${b.x2 + hw} ${b.y2}`,
const midY = (b.y1 + b.y2) / 2; `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 ` return `
<path d="${path}" fill="${b.color}" opacity="0.25"/> <path d="${path}" fill="${b.color}" opacity="0.45"/>
<path d="${path}" fill="none" stroke="${b.color}" stroke-width="0.5" opacity="0.5"/> <path d="${path}" fill="none" stroke="${b.color}" stroke-width="0.5" opacity="0.75"/>
<path d="${center}" fill="none" stroke="${b.color}" stroke-width="1.5" opacity="0.6" stroke-dasharray="6 8" class="flow-dash"/> <path d="${center}" fill="none" stroke="${b.color}" stroke-width="1.5" opacity="0.75" stroke-dasharray="6 8" class="flow-dash"/>
<text x="${midX}" y="${midY - 4}" text-anchor="middle" fill="${b.color}" font-size="9" font-weight="600" opacity="0.9">${b.label}</text>`; <text x="${midX}" y="${midY - 4}" text-anchor="middle" fill="${b.color}" font-size="9" font-weight="600" opacity="0.9">${b.label}</text>`;
} }
@ -306,19 +326,23 @@ function renderFunnel(f: FunnelLayout): string {
const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac); const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac);
const isOverflowing = f.sufficiency === "overflowing"; 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 // Value and drain labels
const val = f.data.currentValue; const val = f.data.currentValue;
const drain = f.data.drainRate; const drain = f.data.drainRate;
const approachingGlow = isApproaching ? `<path d="${vesselPath}" fill="none" stroke="#f59e0b" stroke-width="3" opacity="0.3" class="approaching-glow"/>` : "";
return ` return `
<defs><clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath></defs> <defs><clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath></defs>
${approachingGlow}
<path d="${vesselPath}" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1.5" opacity="0.9"/> <path d="${vesselPath}" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1.5" opacity="0.9"/>
${fillPath ? `<g clip-path="url(#${clipId})"><path d="${fillPath}" fill="${fillColor}" opacity="0.45"/></g>` : ""} ${fillPath ? `<g clip-path="url(#${clipId})"><path d="${fillPath}" fill="${fillColor}" opacity="0.45"/></g>` : ""}
<line x1="${ovEdges.left + 4}" y1="${ovY}" x2="${ovEdges.right - 4}" y2="${ovY}" stroke="${COLORS.overflow}" stroke-width="1.5" stroke-dasharray="5 4" opacity="0.6"/> <line x1="${ovEdges.left + 4}" y1="${ovY}" x2="${ovEdges.right - 4}" y2="${ovY}" stroke="${COLORS.overflow}" stroke-width="1.5" stroke-dasharray="5 4" opacity="0.6"/>
<text x="${cx}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text> <text x="${cx}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${cx}" y="${f.y - 1}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo</text> <text x="${cx}" y="${f.y - 1}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(val)}</text>
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${f.w - 20}" height="3" rx="1.5" fill="${COLORS.surfaceRaised}"/> <rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${f.w - 20}" height="3" rx="1.5" fill="${COLORS.surfaceRaised}"/>
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${(f.w - 20) * Math.min(1, f.fillLevel)}" height="3" rx="1.5" fill="${fillColor}"/>`; <rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${(f.w - 20) * Math.min(1, f.fillLevel)}" height="3" rx="1.5" fill="${fillColor}"/>`;
} }
@ -498,6 +522,8 @@ class FolkFlowRiver extends HTMLElement {
.amount-popover button:hover { opacity: 0.85; } .amount-popover button:hover { opacity: 0.85; }
.flow-dash { animation: dashFlow 1s linear infinite; } .flow-dash { animation: dashFlow 1s linear infinite; }
@keyframes dashFlow { to { stroke-dashoffset: -14; } } @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 } }
</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}">

View File

@ -873,9 +873,10 @@ class FolkFlowsApp extends HTMLElement {
const suffPct = Math.min(100, (data.currentValue / (data.overflowThreshold || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (data.overflowThreshold || 1)) * 100);
const statusClass = sufficiency === "overflowing" ? "flows-status--abundant" const statusClass = sufficiency === "overflowing" ? "flows-status--abundant"
: sufficiency === "approaching" ? "flows-status--approaching"
: "flows-status--seeking"; : "flows-status--seeking";
const statusLabel = sufficiency === "overflowing" ? "Overflowing" : "Seeking"; const statusLabel = sufficiency === "overflowing" ? "Overflowing" : sufficiency === "approaching" ? "Approaching" : "Seeking";
return ` return `
<div class="flows-card"> <div class="flows-card">
@ -1068,6 +1069,18 @@ class FolkFlowsApp extends HTMLElement {
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/> <stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/> <stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
</linearGradient> </linearGradient>
<linearGradient id="funnel-fill-green" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#10b981" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="funnel-fill-amber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f59e0b" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#f59e0b" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="funnel-fill-blue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.3"/>
</linearGradient>
</defs> </defs>
<g id="canvas-transform"> <g id="canvas-transform">
<g id="edge-layer"></g> <g id="edge-layer"></g>
@ -1993,9 +2006,10 @@ class FolkFlowsApp extends HTMLElement {
const fillPct = Math.min(1, d.currentValue / (d.capacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.capacity || 1));
const isOverflow = d.currentValue > d.overflowThreshold; const isOverflow = d.currentValue > d.overflowThreshold;
const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; const isApproaching = !isOverflow && d.currentValue >= d.overflowThreshold * 0.8;
const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : isApproaching ? "#f59e0b" : "var(--rflows-status-sustained)";
const fillColor = borderColorVar; const fillColor = borderColorVar;
const statusLabel = isOverflow ? "Overflow" : "Seeking"; const statusLabel = isOverflow ? "Overflow" : isApproaching ? "Approaching" : "Seeking";
// Vessel shape parameters // Vessel shape parameters
const r = 10; const r = 10;
@ -2137,7 +2151,7 @@ class FolkFlowsApp extends HTMLElement {
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : ""; const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : "";
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))" : ""; const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))" : isApproaching ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
// Rate labels // Rate labels
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`; const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
@ -2145,8 +2159,8 @@ class FolkFlowsApp extends HTMLElement {
const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
// Status badge colors // Status badge colors
const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : "rgba(59,130,246,0.15)"; const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : isApproaching ? "rgba(245,158,11,0.15)" : "rgba(59,130,246,0.15)";
const statusBadgeColor = isOverflow ? "#10b981" : "#3b82f6"; const statusBadgeColor = isOverflow ? "#10b981" : isApproaching ? "#f59e0b" : "#3b82f6";
// Drain spout inset for valve handle positioning // Drain spout inset for valve handle positioning
const drainInset = taperAtBottom; const drainInset = taperAtBottom;
@ -2156,11 +2170,12 @@ class FolkFlowsApp extends HTMLElement {
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath> <clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
</defs> </defs>
${isOverflow ? `<path d="${vesselPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""} ${isOverflow ? `<path d="${vesselPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
${isApproaching ? `<path d="${vesselPath}" fill="none" stroke="#f59e0b" stroke-width="2.5" class="approaching-glow" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${vesselPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/> <path class="node-bg" d="${vesselPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
<g clip-path="url(#${clipId})"> <g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${seekingH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/> <rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${seekingH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/> <rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>` : ""} ${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:url(#${isOverflow ? "funnel-fill-green" : isApproaching ? "funnel-fill-amber" : "funnel-fill-blue"});opacity:var(--rflows-fill-opacity)"/>` : ""}
${shimmerLine} ${shimmerLine}
${thresholdLines} ${thresholdLines}
</g> </g>
@ -2567,41 +2582,7 @@ class FolkFlowsApp extends HTMLElement {
fromSide?: "left" | "right", fromSide?: "left" | "right",
waypoint?: { x: number; y: number }, waypoint?: { x: number; y: number },
): string { ): string {
let d: string; const { d, midX, midY } = this._computeEdgeGeometry(x1, y1, x2, y2, strokeW, fromSide, waypoint);
let midX: number;
let midY: number;
if (waypoint) {
// Cubic Bezier that passes through waypoint at t=0.5:
// P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3
// To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start,
// C2 = (4W - P0 - P3) / 3 blended toward end
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
const cx2 = cx1;
const cy2 = cy1;
// Blend control points to retain start/end tangent direction
const c1x = x1 + (cx1 - x1) * 0.8;
const c1y = y1 + (cy1 - y1) * 0.8;
const c2x = x2 + (cx2 - x2) * 0.8;
const c2y = y2 + (cy2 - y2) * 0.8;
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
midX = waypoint.x;
midY = waypoint.y;
} else if (fromSide) {
// Side port: curve outward horizontally first, then turn toward target
const burst = Math.max(100, strokeW * 8);
const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
midX = (x1 + outwardX + x2) / 3;
midY = (y1 + y2) / 2;
} else {
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
midX = (x1 + x2) / 2;
midY = (y1 + y2) / 2;
}
// Invisible wide hit area for click/selection // Invisible wide hit area for click/selection
const hitPath = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`; const hitPath = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;
@ -2645,8 +2626,8 @@ class FolkFlowsApp extends HTMLElement {
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
} }
/** Pure path computation — returns { d, midX, midY } */ /** Shared edge geometry computation — direction-aware for overflow routing */
private computeEdgePath( private _computeEdgeGeometry(
x1: number, y1: number, x2: number, y2: number, x1: number, y1: number, x2: number, y2: number,
strokeW: number, fromSide?: "left" | "right", strokeW: number, fromSide?: "left" | "right",
waypoint?: { x: number; y: number }, waypoint?: { x: number; y: number },
@ -2663,11 +2644,28 @@ class FolkFlowsApp extends HTMLElement {
midX = waypoint.x; midX = waypoint.x;
midY = waypoint.y; midY = waypoint.y;
} else if (fromSide) { } else if (fromSide) {
const dy = y2 - y1;
const burst = Math.max(100, strokeW * 8); const burst = Math.max(100, strokeW * 8);
const outwardX = fromSide === "left" ? x1 - burst : x1 + burst; const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
midX = (x1 + outwardX + x2) / 3; if (dy < -50) {
midY = (y1 + y2) / 2; // Upward: S-curve with outward horizontal burst then vertical arc above both nodes
const peakY = Math.min(y1, y2) - burst * 0.6;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${outwardX} ${peakY}, ${(x1 + x2) / 2} ${peakY} S ${x2} ${peakY}, ${x2} ${y2}`;
midX = (x1 + x2) / 2;
midY = peakY;
} else if (Math.abs(dy) <= 80) {
// Same-level: wide horizontal outward arc
const arcY = Math.min(y1, y2) - burst * 0.5;
d = `M ${x1} ${y1} C ${outwardX} ${arcY}, ${x2 + (outwardX - x1) * 0.3} ${arcY}, ${x2} ${y2}`;
midX = (outwardX + x2) / 2;
midY = arcY;
} else {
// Downward: existing bezier (works fine)
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + dy * 0.4}, ${x2} ${y2}`;
midX = (x1 + outwardX + x2) / 3;
midY = (y1 + y2) / 2;
}
} else { } else {
const cy1 = y1 + (y2 - y1) * 0.4; const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6; const cy2 = y1 + (y2 - y1) * 0.6;
@ -2678,6 +2676,15 @@ class FolkFlowsApp extends HTMLElement {
return { d, midX, midY }; return { d, midX, midY };
} }
/** @deprecated Use _computeEdgeGeometry instead */
private computeEdgePath(
x1: number, y1: number, x2: number, y2: number,
strokeW: number, fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): { d: string; midX: number; midY: number } {
return this._computeEdgeGeometry(x1, y1, x2, y2, strokeW, fromSide, waypoint);
}
/** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */ /** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */
private updateEdgesDuringDrag(nodeId: string) { private updateEdgesDuringDrag(nodeId: string) {
const edgeLayer = this.shadow.getElementById("edge-layer"); const edgeLayer = this.shadow.getElementById("edge-layer");
@ -3429,7 +3436,7 @@ class FolkFlowsApp extends HTMLElement {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d); const suf = computeSufficiencyState(d);
const fillPct = Math.min(100, Math.round((d.currentValue / (d.overflowThreshold || 1)) * 100)); const fillPct = Math.min(100, Math.round((d.currentValue / (d.overflowThreshold || 1)) * 100));
const fillColor = suf === "seeking" ? "#3b82f6" : "#f59e0b"; const fillColor = suf === "seeking" ? "#3b82f6" : suf === "approaching" ? "#f59e0b" : "#10b981";
const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0); const totalOut = (stats?.totalOutflow || 0) + (stats?.totalOverflow || 0);
const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50; const outflowPct = totalOut > 0 ? Math.round(((stats?.totalOutflow || 0) / totalOut) * 100) : 50;
@ -5029,6 +5036,14 @@ class FolkFlowsApp extends HTMLElement {
private startSimInterval() { private startSimInterval() {
if (this.simInterval) clearInterval(this.simInterval); if (this.simInterval) clearInterval(this.simInterval);
// Pre-warmup: run 5 silent ticks to get flow into the system immediately
for (let i = 0; i < 5; i++) {
this.simTickCount++;
this.nodes = computeInflowRates(this.nodes);
this.nodes = simulateTick(this.nodes);
this.accumulateNodeAnalytics();
}
this.updateCanvasLive();
this.simInterval = setInterval(() => { this.simInterval = setInterval(() => {
this.simTickCount++; this.simTickCount++;
this.nodes = computeInflowRates(this.nodes); this.nodes = computeInflowRates(this.nodes);

View File

@ -44,7 +44,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "bcrg", type: "funnel", position: { x: 560, y: 0 }, id: "bcrg", type: "funnel", position: { x: 560, y: 0 },
data: { data: {
label: "BCRG Treasury", currentValue: 0, drainRate: 6000, label: "BCRG Treasury", currentValue: 8000, drainRate: 6000,
overflowThreshold: 35000, capacity: 50000, inflowRate: 20000, overflowThreshold: 35000, capacity: 50000, inflowRate: 20000,
overflowAllocations: [ overflowAllocations: [
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -63,7 +63,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "programs", type: "funnel", position: { x: 100, y: 600 }, id: "programs", type: "funnel", position: { x: 100, y: 600 },
data: { data: {
label: "Programs", currentValue: 0, drainRate: 2500, label: "Programs", currentValue: 2000, drainRate: 1800,
overflowThreshold: 15000, capacity: 22000, inflowRate: 0, overflowThreshold: 15000, capacity: 22000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] }, { targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] },
@ -78,7 +78,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "operations", type: "funnel", position: { x: 560, y: 600 }, id: "operations", type: "funnel", position: { x: 560, y: 600 },
data: { data: {
label: "Operations", currentValue: 0, drainRate: 2200, label: "Operations", currentValue: 1500, drainRate: 1600,
overflowThreshold: 13200, capacity: 20000, inflowRate: 0, overflowThreshold: 13200, capacity: 20000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -92,7 +92,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "growth", type: "funnel", position: { x: 1020, y: 600 }, id: "growth", type: "funnel", position: { x: 1020, y: 600 },
data: { data: {
label: "Growth", currentValue: 0, drainRate: 1500, label: "Growth", currentValue: 1000, drainRate: 1100,
overflowThreshold: 9000, capacity: 14000, inflowRate: 0, overflowThreshold: 9000, capacity: 14000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] }, { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] },
@ -109,7 +109,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "alice", type: "funnel", position: { x: -100, y: 1250 }, id: "alice", type: "funnel", position: { x: -100, y: 1250 },
data: { data: {
label: "Alice — Research", currentValue: 0, drainRate: 1200, label: "Alice — Research", currentValue: 0, drainRate: 800,
overflowThreshold: 7200, capacity: 10800, inflowRate: 0, overflowThreshold: 7200, capacity: 10800, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] }, { targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] },
@ -123,7 +123,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "bob", type: "funnel", position: { x: 280, y: 1250 }, id: "bob", type: "funnel", position: { x: 280, y: 1250 },
data: { data: {
label: "Bob — Engineering", currentValue: 0, drainRate: 1300, label: "Bob — Engineering", currentValue: 0, drainRate: 850,
overflowThreshold: 7800, capacity: 11700, inflowRate: 0, overflowThreshold: 7800, capacity: 11700, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] }, { targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] },
@ -137,7 +137,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "carol", type: "funnel", position: { x: 660, y: 1250 }, id: "carol", type: "funnel", position: { x: 660, y: 1250 },
data: { data: {
label: "Carol — Comms", currentValue: 0, drainRate: 1100, label: "Carol — Comms", currentValue: 0, drainRate: 750,
overflowThreshold: 6600, capacity: 9900, inflowRate: 0, overflowThreshold: 6600, capacity: 9900, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -151,7 +151,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "dave", type: "funnel", position: { x: 1040, y: 1250 }, id: "dave", type: "funnel", position: { x: 1040, y: 1250 },
data: { data: {
label: "Dave — Design", currentValue: 0, drainRate: 1000, label: "Dave — Design", currentValue: 0, drainRate: 650,
overflowThreshold: 6000, capacity: 9000, inflowRate: 0, overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] },
@ -165,7 +165,7 @@ export const demoNodes: FlowNode[] = [
{ {
id: "eve", type: "funnel", position: { x: 1420, y: 1250 }, id: "eve", type: "funnel", position: { x: 1420, y: 1250 },
data: { data: {
label: "Eve — Governance", currentValue: 0, drainRate: 900, label: "Eve — Governance", currentValue: 0, drainRate: 600,
overflowThreshold: 5400, capacity: 8100, inflowRate: 0, overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] }, { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] },
@ -396,7 +396,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "treasury", type: "funnel", position: { x: 500, y: 0 }, id: "treasury", type: "funnel", position: { x: 500, y: 0 },
data: { data: {
label: "Treasury", currentValue: 0, drainRate: 4000, label: "Treasury", currentValue: 5000, drainRate: 4000,
overflowThreshold: 24000, capacity: 36000, inflowRate: 16000, overflowThreshold: 24000, capacity: 36000, inflowRate: 16000,
overflowAllocations: [ overflowAllocations: [
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -415,7 +415,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "ops", type: "funnel", position: { x: 80, y: 600 }, id: "ops", type: "funnel", position: { x: 80, y: 600 },
data: { data: {
label: "Operations", currentValue: 0, drainRate: 1500, label: "Operations", currentValue: 0, drainRate: 1100,
overflowThreshold: 9000, capacity: 13500, inflowRate: 0, overflowThreshold: 9000, capacity: 13500, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -429,7 +429,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "research", type: "funnel", position: { x: 500, y: 600 }, id: "research", type: "funnel", position: { x: 500, y: 600 },
data: { data: {
label: "Research", currentValue: 0, drainRate: 1400, label: "Research", currentValue: 0, drainRate: 1000,
overflowThreshold: 8400, capacity: 12600, inflowRate: 0, overflowThreshold: 8400, capacity: 12600, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] },
@ -443,7 +443,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "community", type: "funnel", position: { x: 920, y: 600 }, id: "community", type: "funnel", position: { x: 920, y: 600 },
data: { data: {
label: "Community", currentValue: 0, drainRate: 1000, label: "Community", currentValue: 0, drainRate: 700,
overflowThreshold: 6000, capacity: 9000, inflowRate: 0, overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] }, { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] },
@ -458,7 +458,7 @@ export const simDemoNodes: FlowNode[] = [
id: "reserve", type: "funnel", position: { x: 1340, y: 600 }, id: "reserve", type: "funnel", position: { x: 1340, y: 600 },
data: { data: {
label: "Reserve Fund", currentValue: 0, drainRate: 500, label: "Reserve Fund", currentValue: 0, drainRate: 500,
overflowThreshold: 10000, capacity: 20000, inflowRate: 0, overflowThreshold: 5000, capacity: 20000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] }, { targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] },
], ],
@ -474,7 +474,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "infra-team", type: "funnel", position: { x: -100, y: 1250 }, id: "infra-team", type: "funnel", position: { x: -100, y: 1250 },
data: { data: {
label: "Infra Team", currentValue: 0, drainRate: 800, label: "Infra Team", currentValue: 0, drainRate: 550,
overflowThreshold: 4800, capacity: 7200, inflowRate: 0, overflowThreshold: 4800, capacity: 7200, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] }, { targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] },
@ -488,7 +488,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "admin-team", type: "funnel", position: { x: 280, y: 1250 }, id: "admin-team", type: "funnel", position: { x: 280, y: 1250 },
data: { data: {
label: "Admin Team", currentValue: 0, drainRate: 700, label: "Admin Team", currentValue: 0, drainRate: 500,
overflowThreshold: 4200, capacity: 6300, inflowRate: 0, overflowThreshold: 4200, capacity: 6300, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] }, { targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] },
@ -502,7 +502,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "science-team", type: "funnel", position: { x: 660, y: 1250 }, id: "science-team", type: "funnel", position: { x: 660, y: 1250 },
data: { data: {
label: "Science Team", currentValue: 0, drainRate: 900, label: "Science Team", currentValue: 0, drainRate: 600,
overflowThreshold: 5400, capacity: 8100, inflowRate: 0, overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] },
@ -516,7 +516,7 @@ export const simDemoNodes: FlowNode[] = [
{ {
id: "tools-team", type: "funnel", position: { x: 1040, y: 1250 }, id: "tools-team", type: "funnel", position: { x: 1040, y: 1250 },
data: { data: {
label: "Tools Team", currentValue: 0, drainRate: 600, label: "Tools Team", currentValue: 0, drainRate: 400,
overflowThreshold: 3600, capacity: 5400, inflowRate: 0, overflowThreshold: 3600, capacity: 5400, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] },

View File

@ -10,11 +10,14 @@ export interface SimulationConfig {
} }
export const DEFAULT_CONFIG: SimulationConfig = { export const DEFAULT_CONFIG: SimulationConfig = {
tickDivisor: 10, tickDivisor: 5,
}; };
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
return data.currentValue >= data.overflowThreshold ? "overflowing" : "seeking"; const ratio = data.currentValue / (data.overflowThreshold || 1);
if (ratio >= 1) return "overflowing";
if (ratio >= 0.8) return "approaching";
return "seeking";
} }
export function computeSystemSufficiency(nodes: FlowNode[]): number { export function computeSystemSufficiency(nodes: FlowNode[]): number {
@ -37,10 +40,13 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
} }
/** /**
* Sync sourcefunnel allocations into each funnel's inflowRate. * Multi-pass inflow rate computation.
* Funnels with no source wired keep their manual inflowRate (backward compat). * Pass 1: sourcefunnel direct allocations.
* Pass 2+: propagate spending drain shares + overflow excess shares downstream.
* Loops until convergence (max 20 iterations, delta < 0.001).
*/ */
export function computeInflowRates(nodes: FlowNode[]): FlowNode[] { export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
// Pass 1: source → funnel direct allocations
const computed = new Map<string, number>(); const computed = new Map<string, number>();
for (const n of nodes) { for (const n of nodes) {
if (n.type === "source") { if (n.type === "source") {
@ -53,6 +59,52 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
} }
} }
} }
// Pass 2+: propagate spending + overflow downstream through funnel layers
const funnelNodes = nodes.filter((n) => n.type === "funnel").sort((a, b) => a.position.y - b.position.y);
for (let iter = 0; iter < 20; iter++) {
let delta = 0;
for (const fn of funnelNodes) {
const d = fn.data as FunnelNodeData;
const inflow = computed.get(fn.id) ?? d.inflowRate;
if (inflow <= 0) continue;
// Spending drain goes downstream
const drain = Math.min(d.drainRate, inflow);
for (const alloc of d.spendingAllocations) {
const share = drain * (alloc.percentage / 100);
// Only propagate to other funnels
const target = nodes.find((n) => n.id === alloc.targetId);
if (target?.type === "funnel") {
const prev = computed.get(alloc.targetId) ?? 0;
const next = prev + share;
if (Math.abs(next - prev) > 0.001) {
computed.set(alloc.targetId, next);
delta += Math.abs(next - prev);
}
}
}
// Overflow excess goes downstream (estimate: inflow - drain when above threshold)
const netExcess = Math.max(0, inflow - drain);
if (netExcess > 0) {
for (const alloc of d.overflowAllocations) {
const share = netExcess * (alloc.percentage / 100);
const target = nodes.find((n) => n.id === alloc.targetId);
if (target?.type === "funnel") {
const prev = computed.get(alloc.targetId) ?? 0;
const next = prev + share;
if (Math.abs(next - prev) > 0.001) {
computed.set(alloc.targetId, next);
delta += Math.abs(next - prev);
}
}
}
}
}
if (delta < 0.001) break;
}
return nodes.map((n) => { return nodes.map((n) => {
if (n.type === "funnel" && computed.has(n.id)) { if (n.type === "funnel" && computed.has(n.id)) {
return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } }; return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } };
@ -62,13 +114,10 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
} }
/** /**
* Conservation-enforcing tick: for each funnel (Y-order), compute: * Conservation-enforcing tick with convergence loop for circular overflow.
* 1. inflow = inflowRate / tickDivisor + overflow from upstream *
* 2. drain = min(drainRate / tickDivisor, currentValue + inflow) * Wraps funnel processing in up to 3 passes per tick so that upward overflow
* 3. newValue = currentValue + inflow - drain * (e.g. GrowthTreasury) doesn't suffer a 1-tick delay.
* 4. if newValue > overflowThreshold route excess to overflow targets
* 5. distribute drain to spending targets
* 6. clamp to [0, capacity]
*/ */
export function simulateTick( export function simulateTick(
nodes: FlowNode[], nodes: FlowNode[],
@ -80,43 +129,80 @@ export function simulateTick(
.filter((n) => n.type === "funnel") .filter((n) => n.type === "funnel")
.sort((a, b) => a.position.y - b.position.y); .sort((a, b) => a.position.y - b.position.y);
const funnelIds = new Set(funnelNodes.map((n) => n.id));
const overflowIncoming = new Map<string, number>(); const overflowIncoming = new Map<string, number>();
const spendingIncoming = new Map<string, number>(); const spendingIncoming = new Map<string, number>();
const updatedFunnels = new Map<string, FunnelNodeData>(); const updatedFunnels = new Map<string, FunnelNodeData>();
// Initialize funnel data
for (const node of funnelNodes) { for (const node of funnelNodes) {
const src = node.data as FunnelNodeData; updatedFunnels.set(node.id, { ...(node.data as FunnelNodeData) });
const data: FunnelNodeData = { ...src }; }
// 1. Inflow: source rate + overflow received from upstream this tick // Convergence loop: re-process funnels that receive new overflow after being processed
const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0); const processed = new Set<string>();
let value = data.currentValue + inflow; for (let pass = 0; pass < 3; pass++) {
const needsProcessing = pass === 0
? funnelNodes
: funnelNodes.filter((n) => {
// Only re-process if new overflow arrived after we processed it
const incoming = overflowIncoming.get(n.id) ?? 0;
return incoming > 0 && processed.has(n.id);
});
// 2. Drain: flat rate capped by available funds if (pass > 0 && needsProcessing.length === 0) break;
const drain = Math.min(data.drainRate / tickDivisor, value);
value -= drain;
// 3. Overflow: route excess above threshold to downstream for (const node of needsProcessing) {
if (value > data.overflowThreshold && data.overflowAllocations.length > 0) { const data = pass === 0
const excess = value - data.overflowThreshold; ? updatedFunnels.get(node.id)!
for (const alloc of data.overflowAllocations) { : { ...updatedFunnels.get(node.id)! };
const share = excess * (alloc.percentage / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); // 1. Inflow: source rate + overflow received from upstream this tick
const inflow = (pass === 0 ? data.inflowRate / tickDivisor : 0)
+ (overflowIncoming.get(node.id) ?? 0);
if (pass > 0) {
// Clear consumed overflow
overflowIncoming.delete(node.id);
} }
value = data.overflowThreshold;
}
// 4. Distribute drain to spending targets let value = data.currentValue + inflow;
if (drain > 0 && data.spendingAllocations.length > 0) {
for (const alloc of data.spendingAllocations) { // 2. Drain: flat rate capped by available funds (only on first pass)
const share = drain * (alloc.percentage / 100); let drain = 0;
spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); if (pass === 0) {
drain = Math.min(data.drainRate / tickDivisor, value);
value -= drain;
} }
}
// 5. Clamp // 3. Overflow: route excess above threshold to downstream
data.currentValue = Math.max(0, Math.min(value, data.capacity)); if (value > data.overflowThreshold && data.overflowAllocations.length > 0) {
updatedFunnels.set(node.id, data); const excess = value - data.overflowThreshold;
for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
}
value = data.overflowThreshold;
}
// 4. Distribute drain to spending targets (funnel or outcome)
if (drain > 0 && data.spendingAllocations.length > 0) {
for (const alloc of data.spendingAllocations) {
const share = drain * (alloc.percentage / 100);
if (funnelIds.has(alloc.targetId)) {
// Spending to another funnel: add as overflow incoming for convergence
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
} else {
spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share);
}
}
}
// 5. Clamp
data.currentValue = Math.max(0, Math.min(value, data.capacity));
updatedFunnels.set(node.id, data);
processed.add(node.id);
}
} }
// Process outcomes in Y-order so overflow can cascade // Process outcomes in Y-order so overflow can cascade

View File

@ -37,7 +37,7 @@ export interface SourceAllocation {
waypoint?: { x: number; y: number }; waypoint?: { x: number; y: number };
} }
export type SufficiencyState = "seeking" | "overflowing"; export type SufficiencyState = "seeking" | "approaching" | "overflowing";
export interface FunnelNodeData { export interface FunnelNodeData {
label: string; label: string;