feat(rflows): rewrite flow engine — conservation-enforcing simulation + Sankey renderer

Simplify FunnelNodeData from 4-tier thresholds to 2 fields (overflowThreshold + capacity),
replace 3-tier spending multiplier with flat drainRate. Rewrite folk-flow-river.ts as clean
Sankey-style SVG renderer (~580 lines, was ~1043). Add migrateFunnelNodeData() for backward
compat with saved flows. Net -616 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 10:58:53 -07:00
parent f9767191e8
commit 68edfaae66
7 changed files with 467 additions and 1083 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation, MortgagePosition, ReinvestmentPosition, BudgetSegment } from "../lib/types";
import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { PORT_DEFS, migrateFunnelNodeData } from "../lib/types";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, simDemoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { demoNodes, simDemoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
@ -299,7 +299,10 @@ class FolkFlowsApp extends HTMLElement {
const flow = doc.canvasFlows?.[this.currentFlowId]; const flow = doc.canvasFlows?.[this.currentFlowId];
if (flow && !this.saveTimer) { if (flow && !this.saveTimer) {
// Only update if we're not in the middle of saving // Only update if we're not in the middle of saving
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }))); this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({
...n,
data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data },
})));
this.drawCanvasContent(); this.drawCanvasContent();
} }
}); });
@ -349,7 +352,10 @@ class FolkFlowsApp extends HTMLElement {
if (!flow) return; if (!flow) return;
this.currentFlowId = flow.id; this.currentFlowId = flow.id;
this.flowName = flow.name; this.flowName = flow.name;
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }))); this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({
...n,
data: n.type === "funnel" ? migrateFunnelNodeData(n.data) : { ...n.data },
})));
this.localFirstClient?.setActiveFlow(flowId); this.localFirstClient?.setActiveFlow(flowId);
this.restoreViewport(flowId); this.restoreViewport(flowId);
this.loading = false; this.loading = false;
@ -846,19 +852,13 @@ class FolkFlowsApp extends HTMLElement {
private renderFunnelCard(data: FunnelNodeData, id: string): string { private renderFunnelCard(data: FunnelNodeData, id: string): string {
const sufficiency = computeSufficiencyState(data); const sufficiency = computeSufficiencyState(data);
const threshold = data.sufficientThreshold ?? data.maxThreshold; const fillPct = Math.min(100, (data.currentValue / (data.capacity || 1)) * 100);
const fillPct = Math.min(100, (data.currentValue / (data.maxCapacity || 1)) * 100); const suffPct = Math.min(100, (data.currentValue / (data.overflowThreshold || 1)) * 100);
const suffPct = Math.min(100, (data.currentValue / (threshold || 1)) * 100);
const statusClass = sufficiency === "abundant" ? "flows-status--abundant" const statusClass = sufficiency === "overflowing" ? "flows-status--abundant"
: sufficiency === "sufficient" ? "flows-status--sufficient"
: data.currentValue < data.minThreshold ? "flows-status--critical"
: "flows-status--seeking"; : "flows-status--seeking";
const statusLabel = sufficiency === "abundant" ? "Abundant" const statusLabel = sufficiency === "overflowing" ? "Overflowing" : "Seeking";
: sufficiency === "sufficient" ? "Sufficient"
: data.currentValue < data.minThreshold ? "Critical"
: "Seeking";
return ` return `
<div class="flows-card"> <div class="flows-card">
@ -869,12 +869,12 @@ class FolkFlowsApp extends HTMLElement {
</div> </div>
<div class="flows-card__bar-container"> <div class="flows-card__bar-container">
<div class="flows-card__bar" style="width:${fillPct}%"></div> <div class="flows-card__bar" style="width:${fillPct}%"></div>
<div class="flows-card__bar-threshold" style="left:${Math.min(100, (threshold / (data.maxCapacity || 1)) * 100)}%"></div> <div class="flows-card__bar-threshold" style="left:${Math.min(100, (data.overflowThreshold / (data.capacity || 1)) * 100)}%"></div>
</div> </div>
<div class="flows-card__stats"> <div class="flows-card__stats">
<div> <div>
<span class="flows-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span> <span class="flows-card__stat-value">$${Math.floor(data.currentValue).toLocaleString()}</span>
<span class="flows-card__stat-label">/ $${Math.floor(threshold).toLocaleString()}</span> <span class="flows-card__stat-label">/ $${Math.floor(data.overflowThreshold).toLocaleString()}</span>
</div> </div>
<div> <div>
<span class="flows-card__stat-value">${Math.round(suffPct)}%</span> <span class="flows-card__stat-value">${Math.round(suffPct)}%</span>
@ -882,9 +882,9 @@ class FolkFlowsApp extends HTMLElement {
</div> </div>
</div> </div>
<div class="flows-card__thresholds"> <div class="flows-card__thresholds">
<span>Min: $${Math.floor(data.minThreshold).toLocaleString()}</span> <span>Drain: $${Math.floor(data.drainRate).toLocaleString()}/mo</span>
<span>Max: $${Math.floor(data.maxThreshold).toLocaleString()}</span> <span>Overflow: $${Math.floor(data.overflowThreshold).toLocaleString()}</span>
<span>Cap: $${Math.floor(data.maxCapacity).toLocaleString()}</span> <span>Cap: $${Math.floor(data.capacity).toLocaleString()}</span>
</div> </div>
${data.overflowAllocations.length > 0 ? ` ${data.overflowAllocations.length > 0 ? `
<div class="flows-card__allocs"> <div class="flows-card__allocs">
@ -1215,7 +1215,7 @@ class FolkFlowsApp extends HTMLElement {
} }
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const cap = d.maxCapacity || 9000; const cap = d.capacity || 9000;
const w = Math.round(200 + Math.min(160, (cap / 50000) * 160)); const w = Math.round(200 + Math.min(160, (cap / 50000) * 160));
const h = Math.round(200 + Math.min(220, (cap / 50000) * 220)); const h = Math.round(200 + Math.min(220, (cap / 50000) * 220));
return { w, h }; return { w, h };
@ -1275,20 +1275,15 @@ class FolkFlowsApp extends HTMLElement {
const startY = e.clientY; const startY = e.clientY;
if (valveG) { if (valveG) {
const startOutflow = fd.desiredOutflow || 0; const startDrain = fd.drainRate || 0;
handleG.setPointerCapture(e.pointerId); handleG.setPointerCapture(e.pointerId);
const label = handleG.querySelector("text"); const label = handleG.querySelector("text");
const onMove = (ev: PointerEvent) => { const onMove = (ev: PointerEvent) => {
const deltaX = (ev.clientX - startX) / this.canvasZoom; const deltaX = (ev.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50; let newDrain = Math.round((startDrain + (deltaX / s.w) * 10000) / 50) * 50;
newOutflow = Math.max(0, Math.min(10000, newOutflow)); newDrain = Math.max(0, Math.min(10000, newDrain));
fd.desiredOutflow = newOutflow; fd.drainRate = newDrain;
fd.minThreshold = newOutflow; if (label) label.textContent = `${this.formatDollar(newDrain)}/mo ▷`;
fd.maxThreshold = newOutflow * 6;
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
}
if (label) label.textContent = `${this.formatDollar(newOutflow)}/mo ▷`;
}; };
const onUp = () => { const onUp = () => {
handleG.removeEventListener("pointermove", onMove as EventListener); handleG.removeEventListener("pointermove", onMove as EventListener);
@ -1302,14 +1297,14 @@ class FolkFlowsApp extends HTMLElement {
handleG.addEventListener("pointerup", onUp); handleG.addEventListener("pointerup", onUp);
handleG.addEventListener("lostpointercapture", onUp); handleG.addEventListener("lostpointercapture", onUp);
} else { } else {
const startCapacity = fd.maxCapacity || 9000; const startCapacity = fd.capacity || 9000;
handleG.setPointerCapture(e.pointerId); handleG.setPointerCapture(e.pointerId);
const label = handleG.querySelector("text"); const label = handleG.querySelector("text");
const onMove = (ev: PointerEvent) => { const onMove = (ev: PointerEvent) => {
const deltaY = (ev.clientY - startY) / this.canvasZoom; const deltaY = (ev.clientY - startY) / this.canvasZoom;
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500; let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity)); newCapacity = Math.max(Math.round(fd.overflowThreshold * 1.1), Math.min(100000, newCapacity));
fd.maxCapacity = newCapacity; fd.capacity = newCapacity;
if (label) label.textContent = `${this.formatDollar(newCapacity)}`; if (label) label.textContent = `${this.formatDollar(newCapacity)}`;
}; };
const onUp = () => { const onUp = () => {
@ -1844,7 +1839,7 @@ class FolkFlowsApp extends HTMLElement {
// Sum overflow from parent funnels // Sum overflow from parent funnels
if (src.type === "funnel" && src.id !== n.id) { if (src.type === "funnel" && src.id !== n.id) {
const fd = src.data as FunnelNodeData; const fd = src.data as FunnelNodeData;
const excess = Math.max(0, fd.currentValue - fd.maxThreshold); const excess = Math.max(0, fd.currentValue - fd.overflowThreshold);
for (const alloc of fd.overflowAllocations) { for (const alloc of fd.overflowAllocations) {
if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100); if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
} }
@ -1987,19 +1982,18 @@ class FolkFlowsApp extends HTMLElement {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.capacity || 1));
const isOverflow = d.currentValue > d.maxThreshold; const isOverflow = d.currentValue > d.overflowThreshold;
const isCritical = d.currentValue < d.minThreshold; const borderColorVar = isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
const fillColor = borderColorVar; const fillColor = borderColorVar;
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient"; const statusLabel = isOverflow ? "Overflow" : "Seeking";
// Vessel shape parameters // Vessel shape parameters
const r = 10; const r = 10;
const drainW = 60; // narrow drain spout at bottom const drainW = 60; // narrow drain spout at bottom
const outflow = d.desiredOutflow || 0; const drain = d.drainRate || 0;
const outflowRatio = Math.min(1, outflow / 10000); const drainRatio = Math.min(1, drain / 10000);
// taperAtBottom: how far walls inset at the very bottom (in px) // taperAtBottom: how far walls inset at the very bottom (in px)
const taperAtBottom = (w - drainW) / 2; const taperAtBottom = (w - drainW) / 2;
@ -2010,14 +2004,13 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36; const zoneTop = 36;
const zoneBot = h - 6; const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop; const zoneH = zoneBot - zoneTop;
const minFrac = d.minThreshold / (d.maxCapacity || 1); const maxFrac = d.overflowThreshold / (d.capacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac); const maxLineY = zoneTop + zoneH * (1 - maxFrac);
// Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps // Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps
const pipeH = basePipeH; const pipeH = basePipeH;
const pipeY = Math.round(maxLineY - basePipeH / 2); const pipeY = Math.round(maxLineY - basePipeH / 2);
const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold const excessRatio = isOverflow && d.capacity > d.overflowThreshold
? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)) ? Math.min(1, (d.currentValue - d.overflowThreshold) / (d.capacity - d.overflowThreshold))
: 0; : 0;
// Wall inset at pipe Y position for pipe attachment // Wall inset at pipe Y position for pipe attachment
@ -2092,12 +2085,10 @@ class FolkFlowsApp extends HTMLElement {
const clipId = `funnel-clip-${n.id}`; const clipId = `funnel-clip-${n.id}`;
// Zone dimensions // Zone dimensions: below overflow = seeking, above overflow = overflowing
const criticalPct = minFrac; const seekingPct = maxFrac;
const sufficientPct = maxFrac - minFrac;
const overflowPct = Math.max(0, 1 - maxFrac); const overflowPct = Math.max(0, 1 - maxFrac);
const criticalH = zoneH * criticalPct; const seekingH = zoneH * seekingPct;
const sufficientH = zoneH * sufficientPct;
const overflowH = zoneH * overflowPct; const overflowH = zoneH * overflowPct;
// Fill path (tapered polygon) // Fill path (tapered polygon)
@ -2105,17 +2096,12 @@ class FolkFlowsApp extends HTMLElement {
const totalFillH = zoneH * fillPct; const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH; const fillY = zoneTop + zoneH - totalFillH;
// Threshold lines with X endpoints computed from wall taper // Threshold line at overflow point
const minLineY = zoneTop + zoneH * (1 - minFrac);
const minYFrac = (minLineY - zoneTop) / zoneH;
const minInset = this.vesselWallInset(minYFrac, taperAtBottom);
const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom); const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom);
const thresholdLines = ` const thresholdLines = `
<line class="threshold-line" x1="${minInset + 4}" x2="${w - minInset - 4}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="${minInset + 8}" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
<line class="threshold-line" x1="${maxInset + 4}" x2="${w - maxInset - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/> <line class="threshold-line" x1="${maxInset + 4}" x2="${w - maxInset - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="${maxInset + 8}" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`; <text x="${maxInset + 8}" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Overflow</text>`;
// Water surface shimmer line at fill level // Water surface shimmer line at fill level
const shimmerLine = fillPct > 0.01 ? `<line class="water-surface-line" x1="${this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) + 2}" x2="${w - this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) - 2}" y1="${fillY}" y2="${fillY}" stroke="url(#water-surface)" stroke-width="3"/>` : ""; const shimmerLine = fillPct > 0.01 ? `<line class="water-surface-line" x1="${this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) + 2}" x2="${w - this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) - 2}" y1="${fillY}" y2="${fillY}" stroke="url(#water-surface)" stroke-width="3"/>` : "";
@ -2143,17 +2129,16 @@ 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))" : "";
: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
// Rate labels // Rate labels
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`; const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
const excess = Math.max(0, d.currentValue - d.maxThreshold); const excess = Math.max(0, d.currentValue - d.overflowThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
// Status badge colors // Status badge colors
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)"; const statusBadgeBg = isOverflow ? "rgba(16,185,129,0.15)" : "rgba(59,130,246,0.15)";
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b"; const statusBadgeColor = isOverflow ? "#10b981" : "#3b82f6";
// Drain spout inset for valve handle positioning // Drain spout inset for valve handle positioning
const drainInset = taperAtBottom; const drainInset = taperAtBottom;
@ -2165,8 +2150,7 @@ class FolkFlowsApp extends HTMLElement {
${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})"/>` : ""}
<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 + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-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 + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" 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:${fillColor};opacity:var(--rflows-fill-opacity)"/>` : ""}
${shimmerLine} ${shimmerLine}
@ -2184,7 +2168,7 @@ class FolkFlowsApp extends HTMLElement {
<rect x="${drainInset - 8}" y="${h - 16}" width="${drainW + 16}" height="18" rx="5" <rect x="${drainInset - 8}" y="${h - 16}" width="${drainW + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/> style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none"> <text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo ${this.formatDollar(drain)}/mo
</text> </text>
</g> </g>
<!-- Spending drain pipe stub --> <!-- Spending drain pipe stub -->
@ -2214,13 +2198,12 @@ class FolkFlowsApp extends HTMLElement {
<!-- Satisfaction label --> <!-- Satisfaction label -->
<text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10" pointer-events="none">${satLabel}</text> <text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10" pointer-events="none">${satLabel}</text>
<!-- Zone labels (SVG text in clip group) --> <!-- Zone labels (SVG text in clip group) -->
${criticalH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH + criticalH / 2 + 4}" text-anchor="middle" fill="#ef4444" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">CRITICAL</text>` : ""} ${seekingH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + seekingH / 2 + 4}" text-anchor="middle" fill="#3b82f6" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">SEEKING</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">SUFFICIENT</text>` : ""}
${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">OVERFLOW</text>` : ""} ${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">OVERFLOW</text>` : ""}
<!-- Value text --> <!-- Value text -->
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="13" font-weight="500" pointer-events="none">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</text> <text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="13" font-weight="500" pointer-events="none">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}</text>
<!-- Outflow label --> <!-- Drain rate label -->
<text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(outflow)}/mo \u25BE</text> <text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(drain)}/mo \u25BE</text>
<!-- Overflow labels at pipe positions --> <!-- Overflow labels at pipe positions -->
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text> ${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""} <text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""}
@ -2407,17 +2390,13 @@ class FolkFlowsApp extends HTMLElement {
} }
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const excess = Math.max(0, d.currentValue - d.maxThreshold); const excess = Math.max(0, d.currentValue - d.overflowThreshold);
for (const alloc of d.overflowAllocations) { for (const alloc of d.overflowAllocations) {
const flow = excess * (alloc.percentage / 100); const flow = excess * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow; nodeFlows.get(n.id)!.totalOutflow += flow;
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
} }
let rateMultiplier: number; const drain = d.drainRate;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const drain = d.inflowRate * rateMultiplier;
for (const alloc of d.spendingAllocations) { for (const alloc of d.spendingAllocations) {
const flow = drain * (alloc.percentage / 100); const flow = drain * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow; nodeFlows.get(n.id)!.totalOutflow += flow;
@ -2458,13 +2437,9 @@ class FolkFlowsApp extends HTMLElement {
let spendingFlow = 0; let spendingFlow = 0;
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const excess = Math.max(0, d.currentValue - d.maxThreshold); const excess = Math.max(0, d.currentValue - d.overflowThreshold);
for (const alloc of d.overflowAllocations) overflowFlow += excess * (alloc.percentage / 100); for (const alloc of d.overflowAllocations) overflowFlow += excess * (alloc.percentage / 100);
let rateMultiplier: number; const drain = d.drainRate;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const drain = d.inflowRate * rateMultiplier;
for (const alloc of d.spendingAllocations) spendingFlow += drain * (alloc.percentage / 100); for (const alloc of d.spendingAllocations) spendingFlow += drain * (alloc.percentage / 100);
} else if (n.type === "outcome") { } else if (n.type === "outcome") {
const d = n.data as OutcomeNodeData; const d = n.data as OutcomeNodeData;
@ -2518,7 +2493,7 @@ class FolkFlowsApp extends HTMLElement {
for (const alloc of d.overflowAllocations) { for (const alloc of d.overflowAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId); const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue; if (!target) continue;
const excess = Math.max(0, d.currentValue - d.maxThreshold); const excess = Math.max(0, d.currentValue - d.overflowThreshold);
const flowAmount = excess * (alloc.percentage / 100); const flowAmount = excess * (alloc.percentage / 100);
const side = this.getOverflowSideForTarget(n, target); const side = this.getOverflowSideForTarget(n, target);
edges.push({ edges.push({
@ -2530,16 +2505,11 @@ class FolkFlowsApp extends HTMLElement {
waypoint: alloc.waypoint, waypoint: alloc.waypoint,
}); });
} }
// Spending edges — rate-based drain // Spending edges — flat drain rate
for (const alloc of d.spendingAllocations) { for (const alloc of d.spendingAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId); const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue; if (!target) continue;
let rateMultiplier: number; const flowAmount = d.drainRate * (alloc.percentage / 100);
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const drain = d.inflowRate * rateMultiplier;
const flowAmount = drain * (alloc.percentage / 100);
edges.push({ edges.push({
fromNode: n, toNode: target, fromPort: "spending", fromNode: n, toNode: target, fromPort: "spending",
color: "var(--rflows-edge-spending)", flowAmount, color: "var(--rflows-edge-spending)", flowAmount,
@ -2858,8 +2828,7 @@ class FolkFlowsApp extends HTMLElement {
if (n.type === "source") return "var(--rflows-source-border)"; if (n.type === "source") return "var(--rflows-source-border)";
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
return d.currentValue < d.minThreshold ? "var(--rflows-status-critical)" return d.currentValue > d.overflowThreshold ? "var(--rflows-status-overflow)"
: d.currentValue > d.maxThreshold ? "var(--rflows-status-overflow)"
: "var(--rflows-status-sustained)"; : "var(--rflows-status-sustained)";
} }
const d = n.data as OutcomeNodeData; const d = n.data as OutcomeNodeData;
@ -2892,7 +2861,7 @@ class FolkFlowsApp extends HTMLElement {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const h = s.h; const h = s.h;
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop; const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const maxFrac = d.overflowThreshold / (d.capacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac); const maxLineY = zoneTop + zoneH * (1 - maxFrac);
// X position: fully outside the vessel walls (pipe extends outward) // X position: fully outside the vessel walls (pipe extends outward)
const pipeW = this.getFunnelOverflowPipeW(node); const pipeW = this.getFunnelOverflowPipeW(node);
@ -2921,7 +2890,7 @@ class FolkFlowsApp extends HTMLElement {
if (n.type === "funnel" && p.kind === "overflow" && p.side) { if (n.type === "funnel" && p.kind === "overflow" && p.side) {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop; const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const maxFrac = d.overflowThreshold / (d.capacity || 1);
cy = zoneTop + zoneH * (1 - maxFrac); cy = zoneTop + zoneH * (1 - maxFrac);
const pipeW = this.getFunnelOverflowPipeW(n); const pipeW = this.getFunnelOverflowPipeW(n);
cx = p.side === "left" ? -pipeW : s.w + pipeW; cx = p.side === "left" ? -pipeW : s.w + pipeW;
@ -3325,7 +3294,7 @@ class FolkFlowsApp extends HTMLElement {
// Funnels: drag handles instead of config panel // Funnels: drag handles instead of config panel
if (node.type === "funnel") { if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0; const drainVal = d.drainRate || 0;
// Drain spout width for tapered vessel // Drain spout width for tapered vessel
const drainW = 60; const drainW = 60;
const drainInset = (s.w - drainW) / 2; const drainInset = (s.w - drainW) / 2;
@ -3334,7 +3303,7 @@ class FolkFlowsApp extends HTMLElement {
<rect class="valve-drag-handle" x="${drainInset - 8}" y="${s.h - 16}" width="${drainW + 16}" height="18" rx="5" <rect class="valve-drag-handle" x="${drainInset - 8}" y="${s.h - 16}" width="${drainW + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/> style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none"> <text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo ${this.formatDollar(drainVal)}/mo
</text> </text>
<rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5" <rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5"
style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/> style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/>
@ -3425,25 +3394,23 @@ class FolkFlowsApp extends HTMLElement {
private renderFunnelConfigTab(node: FlowNode): string { private renderFunnelConfigTab(node: FlowNode): string {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0; const drainVal = d.drainRate || 0;
return ` return `
<div class="icp-field"><label class="icp-label">Label</label> <div class="icp-field"><label class="icp-label">Label</label>
<input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div> <input class="icp-input" data-icp-field="label" value="${this.esc(d.label)}"/></div>
<div class="icp-range-group"> <div class="icp-range-group">
<span class="icp-range-label">$/mo</span> <span class="icp-range-label">Drain $/mo</span>
<input class="icp-range" type="range" min="0" max="5000" step="50" value="${outflow}" data-icp-outflow="desiredOutflow"/> <input class="icp-range" type="range" min="0" max="5000" step="50" value="${drainVal}" data-icp-outflow="drainRate"/>
<span class="icp-range-value">${this.formatDollar(outflow)}</span> <span class="icp-range-value">${this.formatDollar(drainVal)}</span>
</div> </div>
<div class="icp-threshold-bar-wrap"> <div class="icp-threshold-bar-wrap">
<div class="icp-threshold-bar"> <div class="icp-threshold-bar">
<div class="icp-threshold-seg icp-threshold-seg--red" style="flex:1"></div> <div class="icp-threshold-seg icp-threshold-seg--yellow" style="flex:1"></div>
<div class="icp-threshold-seg icp-threshold-seg--yellow" style="flex:3"></div> <div class="icp-threshold-seg icp-threshold-seg--green" style="flex:1"></div>
<div class="icp-threshold-seg icp-threshold-seg--green" style="flex:2"></div>
</div> </div>
<div class="icp-threshold-labels" data-icp-thresholds> <div class="icp-threshold-labels" data-icp-thresholds>
<span>${this.formatDollar(d.minThreshold)}</span> <span>${this.formatDollar(d.overflowThreshold)}</span>
<span>${this.formatDollar(d.sufficientThreshold ?? d.maxThreshold)}</span> <span>${this.formatDollar(d.capacity)}</span>
<span>${this.formatDollar(d.maxThreshold)}</span>
</div> </div>
</div>`; </div>`;
} }
@ -3471,9 +3438,8 @@ class FolkFlowsApp extends HTMLElement {
if (node.type === "funnel") { if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d); const suf = computeSufficiencyState(d);
const threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(100, Math.round((d.currentValue / (d.overflowThreshold || 1)) * 100));
const fillPct = Math.min(100, Math.round((d.currentValue / (threshold || 1)) * 100)); const fillColor = suf === "seeking" ? "#3b82f6" : "#f59e0b";
const fillColor = suf === "seeking" ? "#3b82f6" : suf === "sufficient" ? "#10b981" : "#f59e0b";
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;
@ -3569,7 +3535,7 @@ class FolkFlowsApp extends HTMLElement {
const valveHandle = overlay.querySelector(".valve-drag-handle"); const valveHandle = overlay.querySelector(".valve-drag-handle");
const heightHandle = overlay.querySelector(".height-drag-handle"); const heightHandle = overlay.querySelector(".height-drag-handle");
// Valve drag (horizontal → desiredOutflow) // Valve drag (horizontal → drainRate)
if (valveHandle) { if (valveHandle) {
valveHandle.addEventListener("pointerdown", (e: Event) => { valveHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent; const pe = e as PointerEvent;
@ -3577,23 +3543,18 @@ class FolkFlowsApp extends HTMLElement {
pe.preventDefault(); pe.preventDefault();
const startX = pe.clientX; const startX = pe.clientX;
const fd = node.data as FunnelNodeData; const fd = node.data as FunnelNodeData;
const startOutflow = fd.desiredOutflow || 0; const startDrain = fd.drainRate || 0;
(valveHandle as Element).setPointerCapture(pe.pointerId); (valveHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => { const onMove = (ev: Event) => {
const me = ev as PointerEvent; const me = ev as PointerEvent;
const deltaX = (me.clientX - startX) / this.canvasZoom; const deltaX = (me.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50; let newDrain = Math.round((startDrain + (deltaX / s.w) * 10000) / 50) * 50;
newOutflow = Math.max(0, Math.min(10000, newOutflow)); newDrain = Math.max(0, Math.min(10000, newDrain));
fd.desiredOutflow = newOutflow; fd.drainRate = newDrain;
fd.minThreshold = newOutflow;
fd.maxThreshold = newOutflow * 6;
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
}
// Update label text only during drag // Update label text only during drag
const label = overlay.querySelector(".valve-drag-label"); const label = overlay.querySelector(".valve-drag-label");
if (label) label.textContent = `${this.formatDollar(newOutflow)}/mo ▷`; if (label) label.textContent = `${this.formatDollar(newDrain)}/mo ▷`;
}; };
const onUp = () => { const onUp = () => {
@ -3613,7 +3574,7 @@ class FolkFlowsApp extends HTMLElement {
}); });
} }
// Height drag (vertical → maxCapacity) // Height drag (vertical → capacity)
if (heightHandle) { if (heightHandle) {
heightHandle.addEventListener("pointerdown", (e: Event) => { heightHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent; const pe = e as PointerEvent;
@ -3621,16 +3582,15 @@ class FolkFlowsApp extends HTMLElement {
pe.preventDefault(); pe.preventDefault();
const startY = pe.clientY; const startY = pe.clientY;
const fd = node.data as FunnelNodeData; const fd = node.data as FunnelNodeData;
const startCapacity = fd.maxCapacity || 9000; const startCapacity = fd.capacity || 9000;
(heightHandle as Element).setPointerCapture(pe.pointerId); (heightHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => { const onMove = (ev: Event) => {
const me = ev as PointerEvent; const me = ev as PointerEvent;
const deltaY = (me.clientY - startY) / this.canvasZoom; const deltaY = (me.clientY - startY) / this.canvasZoom;
// Down = more capacity, up = less
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500; let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity)); newCapacity = Math.max(Math.round(fd.overflowThreshold * 1.1), Math.min(100000, newCapacity));
fd.maxCapacity = newCapacity; fd.capacity = newCapacity;
// Update label // Update label
const label = overlay.querySelector(".height-drag-label"); const label = overlay.querySelector(".height-drag-label");
if (label) label.textContent = `${this.formatDollar(newCapacity)}`; if (label) label.textContent = `${this.formatDollar(newCapacity)}`;
@ -3664,12 +3624,11 @@ class FolkFlowsApp extends HTMLElement {
const taperAtBottom = (s.w - drainW) / 2; const taperAtBottom = (s.w - drainW) / 2;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, { key: "overflowThreshold", value: d.overflowThreshold, color: "var(--rflows-status-overflow)", label: "Overflow" },
{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
]; ];
for (const t of thresholds) { for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1); const frac = t.value / (d.capacity || 1);
const markerY = zoneTop + zoneH * (1 - frac); const markerY = zoneTop + zoneH * (1 - frac);
const yFrac = (markerY - zoneTop) / zoneH; const yFrac = (markerY - zoneTop) / zoneH;
const inset = this.vesselWallInset(yFrac, taperAtBottom); const inset = this.vesselWallInset(yFrac, taperAtBottom);
@ -3763,7 +3722,7 @@ class FolkFlowsApp extends HTMLElement {
const input = el as HTMLInputElement | HTMLSelectElement; const input = el as HTMLInputElement | HTMLSelectElement;
const field = input.dataset.icpField!; const field = input.dataset.icpField!;
const handler = () => { const handler = () => {
const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget"]; const numFields = ["flowRate", "currentValue", "overflowThreshold", "capacity", "inflowRate", "drainRate", "fundingReceived", "fundingTarget"];
const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value; const val = numFields.includes(field) ? parseFloat((input as HTMLInputElement).value) || 0 : input.value;
(node.data as any)[field] = val; (node.data as any)[field] = val;
this.redrawNodeOnly(node); this.redrawNodeOnly(node);
@ -3813,21 +3772,18 @@ class FolkFlowsApp extends HTMLElement {
input.addEventListener("input", () => { input.addEventListener("input", () => {
const val = parseFloat(input.value) || 0; const val = parseFloat(input.value) || 0;
const fd = node.data as FunnelNodeData; const fd = node.data as FunnelNodeData;
fd.desiredOutflow = val; fd.drainRate = val;
const derived = deriveThresholds(val); // Auto-derive thresholds from drain rate (6-month and 12-month multiples)
fd.minThreshold = derived.minThreshold; fd.overflowThreshold = val * 6;
fd.sufficientThreshold = derived.sufficientThreshold; fd.capacity = val * 12;
fd.maxThreshold = derived.maxThreshold;
fd.maxCapacity = derived.maxCapacity;
const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement; const valueSpan = input.parentElement?.querySelector(".icp-range-value") as HTMLElement;
if (valueSpan) valueSpan.textContent = this.formatDollar(val); if (valueSpan) valueSpan.textContent = this.formatDollar(val);
// Update threshold bar labels // Update threshold bar labels
const thresholds = overlay.querySelector("[data-icp-thresholds]"); const thresholds = overlay.querySelector("[data-icp-thresholds]");
if (thresholds) { if (thresholds) {
const spans = thresholds.querySelectorAll("span"); const spans = thresholds.querySelectorAll("span");
if (spans[0]) spans[0].textContent = this.formatDollar(derived.minThreshold); if (spans[0]) spans[0].textContent = this.formatDollar(fd.overflowThreshold);
if (spans[1]) spans[1].textContent = this.formatDollar(derived.sufficientThreshold); if (spans[1]) spans[1].textContent = this.formatDollar(fd.capacity);
if (spans[2]) spans[2].textContent = this.formatDollar(derived.maxThreshold);
} }
this.redrawNodeOnly(node); this.redrawNodeOnly(node);
this.redrawEdges(); this.redrawEdges();
@ -3856,13 +3812,11 @@ class FolkFlowsApp extends HTMLElement {
const s = this.getNodeSize(node); const s = this.getNodeSize(node);
const zoneH = s.h - 6 - 36; const zoneH = s.h - 6 - 36;
const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); const deltaDollars = -(deltaY / zoneH) * (d.capacity || 1);
let newVal = this.inlineEditDragStartValue + deltaDollars; let newVal = this.inlineEditDragStartValue + deltaDollars;
newVal = Math.max(0, Math.min(d.maxCapacity, newVal)); newVal = Math.max(0, Math.min(d.capacity, newVal));
const key = this.inlineEditDragThreshold; const key = this.inlineEditDragThreshold;
if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold); if (key === "overflowThreshold") newVal = Math.min(newVal, d.capacity);
if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold);
if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal));
(node.data as any)[key] = Math.round(newVal); (node.data as any)[key] = Math.round(newVal);
this.redrawNodeInlineEdit(node); this.redrawNodeInlineEdit(node);
}); });
@ -3952,11 +3906,10 @@ class FolkFlowsApp extends HTMLElement {
if (node.type === "funnel") { if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
stats.totalInflow += d.inflowRate; stats.totalInflow += d.inflowRate;
const threshold = d.sufficientThreshold ?? d.maxThreshold; if (d.currentValue >= d.capacity) {
if (d.currentValue >= d.maxCapacity) {
stats.totalOverflow += d.inflowRate * 0.5; stats.totalOverflow += d.inflowRate * 0.5;
stats.totalOutflow += d.inflowRate * 0.5; stats.totalOutflow += d.inflowRate * 0.5;
} else if (d.currentValue >= threshold) { } else if (d.currentValue >= d.overflowThreshold) {
stats.totalOutflow += d.inflowRate * 0.3; stats.totalOutflow += d.inflowRate * 0.3;
} }
stats.fillLevelSum += d.currentValue; stats.fillLevelSum += d.currentValue;
@ -4015,26 +3968,21 @@ class FolkFlowsApp extends HTMLElement {
private renderFunnelEditor(n: FlowNode): string { private renderFunnelEditor(n: FlowNode): string {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const derived = d.desiredOutflow ? deriveThresholds(d.desiredOutflow) : null;
return ` return `
<div class="editor-field"><label class="editor-label">Label</label> <div class="editor-field"><label class="editor-label">Label</label>
<input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div> <input class="editor-input" data-field="label" value="${this.esc(d.label)}"/></div>
<div class="editor-field"><label class="editor-label">Desired Outflow ($/mo)</label> <div class="editor-field"><label class="editor-label">Drain Rate ($/mo)</label>
<input class="editor-input" data-field="desiredOutflow" type="number" value="${d.desiredOutflow ?? 0}"/></div> <input class="editor-input" data-field="drainRate" type="number" value="${d.drainRate ?? 0}"/></div>
<div class="editor-field"><label class="editor-label">Current Value ($)</label> <div class="editor-field"><label class="editor-label">Current Value ($)</label>
<input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div> <input class="editor-input" data-field="currentValue" type="number" value="${d.currentValue}"/></div>
<div class="editor-field"><label class="editor-label">Expected Inflow ($/mo)</label> <div class="editor-field"><label class="editor-label">Expected Inflow ($/mo)</label>
<input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div> <input class="editor-input" data-field="inflowRate" type="number" value="${d.inflowRate}"/></div>
<div class="editor-section"> <div class="editor-section">
<div class="editor-section-title">Thresholds ${derived ? "(auto-derived from outflow)" : ""}</div> <div class="editor-section-title">Thresholds</div>
<div class="editor-field"><label class="editor-label">Min (1mo)${derived ? `${this.formatDollar(derived.minThreshold)}` : ""}</label> <div class="editor-field"><label class="editor-label">Overflow Threshold</label>
<input class="editor-input" data-field="minThreshold" type="number" value="${d.minThreshold}"/></div> <input class="editor-input" data-field="overflowThreshold" type="number" value="${d.overflowThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Sufficient (4mo)${derived ? `${this.formatDollar(derived.sufficientThreshold)}` : ""}</label> <div class="editor-field"><label class="editor-label">Capacity</label>
<input class="editor-input" data-field="sufficientThreshold" type="number" value="${d.sufficientThreshold ?? d.maxThreshold}"/></div> <input class="editor-input" data-field="capacity" type="number" value="${d.capacity}"/></div>
<div class="editor-field"><label class="editor-label">Overflow (6mo)${derived ? `${this.formatDollar(derived.maxThreshold)}` : ""}</label>
<input class="editor-input" data-field="maxThreshold" type="number" value="${d.maxThreshold}"/></div>
<div class="editor-field"><label class="editor-label">Max Capacity (9mo)${derived ? `${this.formatDollar(derived.maxCapacity)}` : ""}</label>
<input class="editor-input" data-field="maxCapacity" type="number" value="${d.maxCapacity}"/></div>
</div> </div>
${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)} ${this.renderAllocEditor("Overflow Allocations", d.overflowAllocations)}
${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`; ${this.renderAllocEditor("Spending Allocations", d.spendingAllocations)}`;
@ -4540,22 +4488,8 @@ class FolkFlowsApp extends HTMLElement {
const field = (input as HTMLElement).dataset.field; const field = (input as HTMLElement).dataset.field;
if (!field) return; if (!field) return;
const val = (input as HTMLInputElement).value; const val = (input as HTMLInputElement).value;
const numFields = ["flowRate", "currentValue", "minThreshold", "maxThreshold", "maxCapacity", "inflowRate", "sufficientThreshold", "fundingReceived", "fundingTarget", "desiredOutflow"]; const numFields = ["flowRate", "currentValue", "overflowThreshold", "capacity", "inflowRate", "drainRate", "fundingReceived", "fundingTarget"];
(node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val; (node.data as any)[field] = numFields.includes(field) ? parseFloat(val) || 0 : val;
// Auto-derive thresholds when desiredOutflow changes
if (field === "desiredOutflow" && node.type === "funnel") {
const fd = node.data as FunnelNodeData;
if (fd.desiredOutflow) {
const derived = deriveThresholds(fd.desiredOutflow);
fd.minThreshold = derived.minThreshold;
fd.sufficientThreshold = derived.sufficientThreshold;
fd.maxThreshold = derived.maxThreshold;
fd.maxCapacity = derived.maxCapacity;
// Re-render the editor to reflect updated values
this.openEditor(node.id);
return;
}
}
this.drawCanvasContent(); this.drawCanvasContent();
this.updateSufficiencyBadge(); this.updateSufficiencyBadge();
this.scheduleSave(); this.scheduleSave();
@ -4600,8 +4534,8 @@ class FolkFlowsApp extends HTMLElement {
} else if (node.type === "funnel") { } else if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const suf = computeSufficiencyState(d); const suf = computeSufficiencyState(d);
html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.sufficientThreshold ?? d.maxThreshold).toLocaleString()}</div>`; html += `<div class="flows-node-tooltip__stat">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}</div>`;
html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "sufficient" || suf === "abundant" ? "var(--rflows-sufficiency-highlight)" : "var(--rs-text-secondary)"}">${suf}</div>`; html += `<div class="flows-node-tooltip__stat" style="text-transform:capitalize;color:${suf === "overflowing" ? "var(--rflows-sufficiency-highlight)" : "var(--rs-text-secondary)"}">${suf}</div>`;
} else { } else {
const d = node.data as OutcomeNodeData; const d = node.data as OutcomeNodeData;
const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0; const pct = d.fundingTarget > 0 ? Math.round((d.fundingReceived / d.fundingTarget) * 100) : 0;
@ -4976,9 +4910,8 @@ class FolkFlowsApp extends HTMLElement {
data = { label: defaultLabel, flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData; data = { label: defaultLabel, flowRate: 1000, sourceType: "card", targetAllocations: [] } as SourceNodeData;
} else if (type === "funnel") { } else if (type === "funnel") {
data = { data = {
label: "New Funnel", currentValue: 0, desiredOutflow: 5000, label: "New Funnel", currentValue: 0, drainRate: 5000,
minThreshold: 5000, sufficientThreshold: 15000, maxThreshold: 30000, overflowThreshold: 30000, capacity: 45000, inflowRate: 0,
maxCapacity: 45000, inflowRate: 0, dynamicOverflow: false,
overflowAllocations: [], spendingAllocations: [], overflowAllocations: [], spendingAllocations: [],
} as FunnelNodeData; } as FunnelNodeData;
} else { } else {
@ -5068,7 +5001,7 @@ class FolkFlowsApp extends HTMLElement {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
const w = s.w, h = s.h; const w = s.w, h = s.h;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.capacity || 1));
const drainW = 60; const drainW = 60;
const taperAtBottom = (w - drainW) / 2; const taperAtBottom = (w - drainW) / 2;
const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom); const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
@ -5078,10 +5011,9 @@ class FolkFlowsApp extends HTMLElement {
didPatch = true; didPatch = true;
} }
// Patch value text // Patch value text
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null; const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null;
if (valText) { if (valText) {
valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`; valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}`;
} }
} }
@ -5164,7 +5096,7 @@ class FolkFlowsApp extends HTMLElement {
if (!json) return; if (!json) return;
const nodes = JSON.parse(json) as FlowNode[]; const nodes = JSON.parse(json) as FlowNode[];
if (Array.isArray(nodes) && nodes.length > 0) { if (Array.isArray(nodes) && nodes.length > 0) {
this.nodes = nodes; this.nodes = nodes.map((n: any) => n.type === "funnel" ? { ...n, data: migrateFunnelNodeData(n.data) } : n);
this.drawCanvasContent(); this.drawCanvasContent();
this.fitView(); this.fitView();
} }
@ -5824,10 +5756,10 @@ class FolkFlowsApp extends HTMLElement {
data: { data: {
label: 'Budget Pool', label: 'Budget Pool',
currentValue: this.budgetTotalAmount, currentValue: this.budgetTotalAmount,
minThreshold: this.budgetTotalAmount * 0.2, overflowThreshold: this.budgetTotalAmount * 0.8,
maxThreshold: this.budgetTotalAmount * 0.8, capacity: this.budgetTotalAmount,
maxCapacity: this.budgetTotalAmount,
inflowRate: 0, inflowRate: 0,
drainRate: 0,
overflowAllocations: [], overflowAllocations: [],
spendingAllocations: [], spendingAllocations: [],
} as FunnelNodeData, } as FunnelNodeData,

View File

@ -3,7 +3,7 @@
* Shared between folk-flows-app (data loading) and folk-flow-river (rendering). * Shared between folk-flows-app (data loading) and folk-flow-river (rendering).
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from "./types"; import { migrateFunnelNodeData, type FlowNode, type FunnelNodeData, type OutcomeNodeData, type SourceNodeData } from "./types";
const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"]; const SPENDING_COLORS = ["#3b82f6", "#8b5cf6", "#ec4899", "#06b6d4", "#10b981", "#6366f1"];
const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"]; const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc2626", "#ea580c"];
@ -39,15 +39,10 @@ export function mapFlowToNodes(apiData: any): FlowNode[] {
id: funnel.id, id: funnel.id,
type: "funnel", type: "funnel",
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: migrateFunnelNodeData({
...funnel,
label: funnel.label || funnel.name || "Funnel", label: funnel.label || funnel.name || "Funnel",
currentValue: funnel.currentValue ?? funnel.balance ?? 0, currentValue: funnel.currentValue ?? funnel.balance ?? 0,
minThreshold: funnel.minThreshold ?? 0,
maxThreshold: funnel.maxThreshold ?? funnel.currentValue ?? 10000,
maxCapacity: funnel.maxCapacity ?? funnel.maxThreshold ?? 100000,
inflowRate: funnel.inflowRate ?? 0,
sufficientThreshold: funnel.sufficientThreshold,
dynamicOverflow: funnel.dynamicOverflow ?? false,
overflowAllocations: (funnel.overflowAllocations || []).map((a: any, i: number) => ({ overflowAllocations: (funnel.overflowAllocations || []).map((a: any, i: number) => ({
targetId: a.targetId, targetId: a.targetId,
percentage: a.percentage, percentage: a.percentage,
@ -58,7 +53,7 @@ export function mapFlowToNodes(apiData: any): FlowNode[] {
percentage: a.percentage, percentage: a.percentage,
color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length], color: a.color || SPENDING_COLORS[i % SPENDING_COLORS.length],
})), })),
} as FunnelNodeData, }),
}); });
} }
} }

View File

@ -44,9 +44,8 @@ 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, desiredOutflow: 6000, label: "BCRG Treasury", currentValue: 0, drainRate: 6000,
minThreshold: 6000, sufficientThreshold: 25000, maxThreshold: 35000, overflowThreshold: 35000, capacity: 50000, inflowRate: 20000,
maxCapacity: 50000, inflowRate: 20000, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -64,9 +63,8 @@ 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, desiredOutflow: 2500, label: "Programs", currentValue: 0, drainRate: 2500,
minThreshold: 2500, sufficientThreshold: 10000, maxThreshold: 15000, overflowThreshold: 15000, capacity: 22000, inflowRate: 0,
maxCapacity: 22000, inflowRate: 0, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] }, { targetId: "operations", percentage: 60, color: OVERFLOW_COLORS[1] },
{ targetId: "growth", percentage: 40, color: OVERFLOW_COLORS[2] }, { targetId: "growth", percentage: 40, color: OVERFLOW_COLORS[2] },
@ -80,9 +78,8 @@ 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, desiredOutflow: 2200, label: "Operations", currentValue: 0, drainRate: 2200,
minThreshold: 2200, sufficientThreshold: 8800, maxThreshold: 13200, overflowThreshold: 13200, capacity: 20000, inflowRate: 0,
maxCapacity: 20000, inflowRate: 0, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "growth", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -95,9 +92,8 @@ 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, desiredOutflow: 1500, label: "Growth", currentValue: 0, drainRate: 1500,
minThreshold: 1500, sufficientThreshold: 6000, maxThreshold: 9000, overflowThreshold: 9000, capacity: 14000, inflowRate: 0,
maxCapacity: 14000, inflowRate: 0, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] }, { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[3] },
], ],
@ -113,9 +109,8 @@ 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, desiredOutflow: 1200, label: "Alice — Research", currentValue: 0, drainRate: 1200,
minThreshold: 1200, sufficientThreshold: 4800, maxThreshold: 7200, overflowThreshold: 7200, capacity: 10800, inflowRate: 0,
maxCapacity: 10800, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] }, { targetId: "bob", percentage: 100, color: OVERFLOW_COLORS[4] },
], ],
@ -128,9 +123,8 @@ 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, desiredOutflow: 1300, label: "Bob — Engineering", currentValue: 0, drainRate: 1300,
minThreshold: 1300, sufficientThreshold: 5200, maxThreshold: 7800, overflowThreshold: 7800, capacity: 11700, inflowRate: 0,
maxCapacity: 11700, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] }, { targetId: "programs", percentage: 100, color: OVERFLOW_COLORS[5] },
], ],
@ -143,9 +137,8 @@ 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, desiredOutflow: 1100, label: "Carol — Comms", currentValue: 0, drainRate: 1100,
minThreshold: 1100, sufficientThreshold: 4400, maxThreshold: 6600, overflowThreshold: 6600, capacity: 9900, inflowRate: 0,
maxCapacity: 9900, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "dave", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -158,9 +151,8 @@ 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, desiredOutflow: 1000, label: "Dave — Design", currentValue: 0, drainRate: 1000,
minThreshold: 1000, sufficientThreshold: 4000, maxThreshold: 6000, overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
maxCapacity: 9000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "operations", percentage: 100, color: OVERFLOW_COLORS[1] },
], ],
@ -173,9 +165,8 @@ 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, desiredOutflow: 900, label: "Eve — Governance", currentValue: 0, drainRate: 900,
minThreshold: 900, sufficientThreshold: 3600, maxThreshold: 5400, overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
maxCapacity: 8100, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] }, { targetId: "bcrg", percentage: 100, color: OVERFLOW_COLORS[2] },
], ],
@ -405,9 +396,8 @@ 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, desiredOutflow: 4000, label: "Treasury", currentValue: 0, drainRate: 4000,
minThreshold: 4000, sufficientThreshold: 16000, maxThreshold: 24000, overflowThreshold: 24000, capacity: 36000, inflowRate: 16000,
maxCapacity: 36000, inflowRate: 16000, dynamicOverflow: true,
overflowAllocations: [ overflowAllocations: [
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -425,9 +415,8 @@ 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, desiredOutflow: 1500, label: "Operations", currentValue: 0, drainRate: 1500,
minThreshold: 1500, sufficientThreshold: 6000, maxThreshold: 9000, overflowThreshold: 9000, capacity: 13500, inflowRate: 0,
maxCapacity: 13500, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -440,9 +429,8 @@ 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, desiredOutflow: 1400, label: "Research", currentValue: 0, drainRate: 1400,
minThreshold: 1400, sufficientThreshold: 5600, maxThreshold: 8400, overflowThreshold: 8400, capacity: 12600, inflowRate: 0,
maxCapacity: 12600, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "community", percentage: 100, color: OVERFLOW_COLORS[1] },
], ],
@ -455,9 +443,8 @@ 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, desiredOutflow: 1000, label: "Community", currentValue: 0, drainRate: 1000,
minThreshold: 1000, sufficientThreshold: 4000, maxThreshold: 6000, overflowThreshold: 6000, capacity: 9000, inflowRate: 0,
maxCapacity: 9000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] }, { targetId: "reserve", percentage: 100, color: OVERFLOW_COLORS[2] },
], ],
@ -470,9 +457,8 @@ 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, desiredOutflow: 500, label: "Reserve Fund", currentValue: 0, drainRate: 500,
minThreshold: 500, sufficientThreshold: 5000, maxThreshold: 10000, overflowThreshold: 10000, capacity: 20000, inflowRate: 0,
maxCapacity: 20000, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] }, { targetId: "treasury", percentage: 100, color: OVERFLOW_COLORS[3] },
], ],
@ -488,9 +474,8 @@ 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, desiredOutflow: 800, label: "Infra Team", currentValue: 0, drainRate: 800,
minThreshold: 800, sufficientThreshold: 3200, maxThreshold: 4800, overflowThreshold: 4800, capacity: 7200, inflowRate: 0,
maxCapacity: 7200, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] }, { targetId: "admin-team", percentage: 100, color: OVERFLOW_COLORS[4] },
], ],
@ -503,9 +488,8 @@ 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, desiredOutflow: 700, label: "Admin Team", currentValue: 0, drainRate: 700,
minThreshold: 700, sufficientThreshold: 2800, maxThreshold: 4200, overflowThreshold: 4200, capacity: 6300, inflowRate: 0,
maxCapacity: 6300, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] }, { targetId: "ops", percentage: 100, color: OVERFLOW_COLORS[5] },
], ],
@ -518,9 +502,8 @@ 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, desiredOutflow: 900, label: "Science Team", currentValue: 0, drainRate: 900,
minThreshold: 900, sufficientThreshold: 3600, maxThreshold: 5400, overflowThreshold: 5400, capacity: 8100, inflowRate: 0,
maxCapacity: 8100, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] }, { targetId: "tools-team", percentage: 100, color: OVERFLOW_COLORS[0] },
], ],
@ -533,9 +516,8 @@ 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, desiredOutflow: 600, label: "Tools Team", currentValue: 0, drainRate: 600,
minThreshold: 600, sufficientThreshold: 2400, maxThreshold: 3600, overflowThreshold: 3600, capacity: 5400, inflowRate: 0,
maxCapacity: 5400, inflowRate: 0,
overflowAllocations: [ overflowAllocations: [
{ targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] }, { targetId: "research", percentage: 100, color: OVERFLOW_COLORS[1] },
], ],

View File

@ -1,68 +1,20 @@
/** /**
* Flow simulation engine pure function, no framework dependencies. * Flow simulation engine pure function, no framework dependencies.
* Ported from rflows-online/lib/simulation.ts. * Enforces strict conservation: every dollar is accounted for (in = out).
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types";
import { deriveThresholds } from "./types";
export interface SimulationConfig { export interface SimulationConfig {
tickDivisor: number; tickDivisor: number;
spendingRateHealthy: number;
spendingRateOverflow: number;
spendingRateCritical: number;
} }
export const DEFAULT_CONFIG: SimulationConfig = { export const DEFAULT_CONFIG: SimulationConfig = {
tickDivisor: 10, tickDivisor: 10,
spendingRateHealthy: 0.5,
spendingRateOverflow: 0.8,
spendingRateCritical: 0.1,
}; };
export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState {
const threshold = data.sufficientThreshold ?? data.maxThreshold; return data.currentValue >= data.overflowThreshold ? "overflowing" : "seeking";
if (data.currentValue >= data.maxCapacity) return "abundant";
if (data.currentValue >= threshold) return "sufficient";
return "seeking";
}
export function computeNeedWeights(
targetIds: string[],
allNodes: FlowNode[],
): Map<string, number> {
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
const needs = new Map<string, number>();
for (const tid of targetIds) {
const node = nodeMap.get(tid);
if (!node) { needs.set(tid, 0); continue; }
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const need = Math.max(0, 1 - d.currentValue / (threshold || 1));
needs.set(tid, need);
} else if (node.type === "outcome") {
const d = node.data as OutcomeNodeData;
const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1));
needs.set(tid, need);
} else {
needs.set(tid, 0);
}
}
const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0);
const weights = new Map<string, number>();
if (totalNeed === 0) {
const equal = targetIds.length > 0 ? 100 / targetIds.length : 0;
targetIds.forEach((id) => weights.set(id, equal));
} else {
needs.forEach((need, id) => {
weights.set(id, (need / totalNeed) * 100);
});
}
return weights;
} }
export function computeSystemSufficiency(nodes: FlowNode[]): number { export function computeSystemSufficiency(nodes: FlowNode[]): number {
@ -72,8 +24,7 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
for (const node of nodes) { for (const node of nodes) {
if (node.type === "funnel") { if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const threshold = d.sufficientThreshold ?? d.maxThreshold; sum += Math.min(1, d.currentValue / (d.overflowThreshold || 1));
sum += Math.min(1, d.currentValue / (threshold || 1));
count++; count++;
} else if (node.type === "outcome") { } else if (node.type === "outcome") {
const d = node.data as OutcomeNodeData; const d = node.data as OutcomeNodeData;
@ -110,11 +61,20 @@ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
}); });
} }
/**
* Conservation-enforcing tick: for each funnel (Y-order), compute:
* 1. inflow = inflowRate / tickDivisor + overflow from upstream
* 2. drain = min(drainRate / tickDivisor, currentValue + inflow)
* 3. newValue = currentValue + inflow - drain
* 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[],
config: SimulationConfig = DEFAULT_CONFIG, config: SimulationConfig = DEFAULT_CONFIG,
): FlowNode[] { ): FlowNode[] {
const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config; const { tickDivisor } = config;
const funnelNodes = nodes const funnelNodes = nodes
.filter((n) => n.type === "funnel") .filter((n) => n.type === "funnel")
@ -128,65 +88,38 @@ export function simulateTick(
const src = node.data as FunnelNodeData; const src = node.data as FunnelNodeData;
const data: FunnelNodeData = { ...src }; const data: FunnelNodeData = { ...src };
// Auto-derive thresholds from desiredOutflow when present // 1. Inflow: source rate + overflow received from upstream this tick
if (data.desiredOutflow) { const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0);
const derived = deriveThresholds(data.desiredOutflow); let value = data.currentValue + inflow;
data.minThreshold = derived.minThreshold;
data.sufficientThreshold = derived.sufficientThreshold;
data.maxThreshold = derived.maxThreshold;
data.maxCapacity = derived.maxCapacity;
}
let value = data.currentValue + data.inflowRate / tickDivisor; // 2. Drain: flat rate capped by available funds
value += overflowIncoming.get(node.id) ?? 0; const drain = Math.min(data.drainRate / tickDivisor, value);
value = Math.min(value, data.maxCapacity); value -= drain;
if (value > data.maxThreshold && data.overflowAllocations.length > 0) { // 3. Overflow: route excess above threshold to downstream
const excess = value - data.maxThreshold; if (value > data.overflowThreshold && data.overflowAllocations.length > 0) {
const excess = value - data.overflowThreshold;
if (data.dynamicOverflow) {
const targetIds = data.overflowAllocations.map((a) => a.targetId);
const needWeights = computeNeedWeights(targetIds, nodes);
for (const alloc of data.overflowAllocations) {
const weight = needWeights.get(alloc.targetId) ?? 0;
const share = excess * (weight / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
}
} else {
for (const alloc of data.overflowAllocations) { for (const alloc of data.overflowAllocations) {
const share = excess * (alloc.percentage / 100); const share = excess * (alloc.percentage / 100);
overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share);
} }
} value = data.overflowThreshold;
value = data.maxThreshold;
} }
if (value > 0 && data.spendingAllocations.length > 0) { // 4. Distribute drain to spending targets
let rateMultiplier: number; if (drain > 0 && data.spendingAllocations.length > 0) {
if (value > data.maxThreshold) {
rateMultiplier = spendingRateOverflow;
} else if (value >= data.minThreshold) {
rateMultiplier = spendingRateHealthy;
} else {
rateMultiplier = spendingRateCritical;
}
const baseRate = data.desiredOutflow || data.inflowRate;
let drain = (baseRate / tickDivisor) * rateMultiplier;
drain = Math.min(drain, value);
value -= drain;
for (const alloc of data.spendingAllocations) { for (const alloc of data.spendingAllocations) {
const share = drain * (alloc.percentage / 100); const share = drain * (alloc.percentage / 100);
spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share);
} }
} }
data.currentValue = Math.max(0, value); // 5. Clamp
data.currentValue = Math.max(0, Math.min(value, data.capacity));
updatedFunnels.set(node.id, data); updatedFunnels.set(node.id, data);
} }
// Process outcomes in Y-order (like funnels) so overflow can cascade // Process outcomes in Y-order so overflow can cascade
const outcomeNodes = nodes const outcomeNodes = nodes
.filter((n) => n.type === "outcome") .filter((n) => n.type === "outcome")
.sort((a, b) => a.position.y - b.position.y); .sort((a, b) => a.position.y - b.position.y);

View File

@ -37,30 +37,33 @@ export interface SourceAllocation {
waypoint?: { x: number; y: number }; waypoint?: { x: number; y: number };
} }
export type SufficiencyState = "seeking" | "sufficient" | "abundant"; export type SufficiencyState = "seeking" | "overflowing";
export interface FunnelNodeData { export interface FunnelNodeData {
label: string; label: string;
currentValue: number; currentValue: number; // runtime accumulator, not user-editable
minThreshold: number; overflowThreshold: number; // triggers overflow routing
maxThreshold: number; capacity: number; // visual max / clamp
maxCapacity: number; inflowRate: number; // computed from upstream
inflowRate: number; drainRate: number; // flat drain $/mo
desiredOutflow?: number;
sufficientThreshold?: number;
dynamicOverflow?: boolean;
overflowAllocations: OverflowAllocation[]; overflowAllocations: OverflowAllocation[];
spendingAllocations: SpendingAllocation[]; spendingAllocations: SpendingAllocation[];
source?: IntegrationSource; source?: IntegrationSource;
[key: string]: unknown; [key: string]: unknown;
} }
export function deriveThresholds(desiredOutflow: number) { /** Migrate legacy FunnelNodeData (4-tier thresholds) to new 2-tier format. */
export function migrateFunnelNodeData(d: any): FunnelNodeData {
return { return {
minThreshold: desiredOutflow * 1, // 1 month runway label: d.label ?? "Funnel",
sufficientThreshold: desiredOutflow * 4, // 4 months runway (1 min + 3 buffer) currentValue: d.currentValue ?? 0,
maxThreshold: desiredOutflow * 6, // overflow point overflowThreshold: d.overflowThreshold ?? d.maxThreshold ?? 0,
maxCapacity: desiredOutflow * 9, // visual max capacity: d.capacity ?? d.maxCapacity ?? 0,
inflowRate: d.inflowRate ?? 0,
drainRate: d.drainRate ?? d.desiredOutflow ?? d.inflowRate ?? 0,
overflowAllocations: d.overflowAllocations ?? [],
spendingAllocations: d.spendingAllocations ?? [],
source: d.source,
}; };
} }

View File

@ -785,8 +785,8 @@ routes.post("/api/budgets/segments", async (c) => {
const flowsScripts = ` const flowsScripts = `
<script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/lz-string@1.5.0/libs/lz-string.min.js"></script>
<script type="module" src="/modules/rflows/folk-flows-app.js?v=2"></script> <script type="module" src="/modules/rflows/folk-flows-app.js?v=3"></script>
<script type="module" src="/modules/rflows/folk-flow-river.js?v=2"></script>`; <script type="module" src="/modules/rflows/folk-flow-river.js?v=3"></script>`;
const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`; const flowsStyles = `<link rel="stylesheet" href="/modules/rflows/flows.css">`;