feat(rflows): close source→funnel flow gap, interactive sizing + drag handles

Source nodes now drive funnel inflow rates via computeInflowRates() which
sums source allocations before each simulation tick. Source width scales
with flowRate, funnel height scales linearly with capacity, and valve/
capacity drag handles are always visible on hover (no inline-edit needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-06 11:52:09 -08:00
parent 5b2862afd7
commit eeae7d2aa1
3 changed files with 136 additions and 14 deletions

View File

@ -274,6 +274,13 @@
.flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; } .flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; }
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); } .node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
/* Funnel drag handles — hidden by default, visible on hover */
.funnel-valve-handle, .funnel-height-handle { opacity: 0; transition: opacity 0.2s; }
.flow-node:hover .funnel-valve-handle,
.flow-node:hover .funnel-height-handle { opacity: 0.8; }
.funnel-valve-handle:hover { opacity: 1 !important; }
.funnel-height-handle:hover { opacity: 1 !important; }
/* HTML card nodes (foreignObject) */ /* HTML card nodes (foreignObject) */
.node-card { .node-card {
background: white; border-radius: 12px; overflow: hidden; background: white; border-radius: 12px; overflow: hidden;

View File

@ -13,7 +13,7 @@
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS, deriveThresholds } from "../lib/types"; import { PORT_DEFS, deriveThresholds } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
import { mapFlowToNodes } from "../lib/map-flow"; import { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas"; import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
@ -215,7 +215,7 @@ 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 = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })); this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
this.drawCanvasContent(); this.drawCanvasContent();
} }
}); });
@ -259,7 +259,7 @@ 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 = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })); this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
this.localFirstClient?.setActiveFlow(flowId); this.localFirstClient?.setActiveFlow(flowId);
this.restoreViewport(flowId); this.restoreViewport(flowId);
this.loading = false; this.loading = false;
@ -1042,14 +1042,17 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } { private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") { if (n.type === "source") {
return { w: 220, h: 120 }; const d = n.data as SourceNodeData;
const baseW = 180;
const w = Math.round(baseW + Math.min(120, Math.sqrt(d.flowRate / 100) * 20));
return { w, h: 120 };
} }
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const baseW = 280, baseH = 250; const baseW = 280;
const hRef = d.maxCapacity || 9000; const cap = d.maxCapacity || 9000;
const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35; const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) }; return { w: baseW, h: Math.max(200, h) };
} }
return { w: 220, h: 180 }; // outcome card return { w: 220, h: 180 }; // outcome card
} }
@ -1083,6 +1086,75 @@ class FolkFlowsApp extends HTMLElement {
this.updateCanvasTransform(); this.updateCanvasTransform();
}, { passive: false }); }, { passive: false });
// Delegated funnel valve + height drag handles
svg.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element;
const valveG = target.closest(".funnel-valve-handle") as SVGGElement | null;
const heightG = target.closest(".funnel-height-handle") as SVGGElement | null;
const handleG = valveG || heightG;
if (!handleG) return;
e.stopPropagation();
e.preventDefault();
const nodeId = handleG.getAttribute("data-node-id");
const node = this.nodes.find((n) => n.id === nodeId);
if (!node || node.type !== "funnel") return;
const fd = node.data as FunnelNodeData;
const s = this.getNodeSize(node);
const startX = e.clientX;
const startY = e.clientY;
if (valveG) {
const startOutflow = fd.desiredOutflow || 0;
handleG.setPointerCapture(e.pointerId);
const label = handleG.querySelector("text");
const onMove = (ev: PointerEvent) => {
const deltaX = (ev.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
newOutflow = Math.max(0, Math.min(10000, newOutflow));
fd.desiredOutflow = newOutflow;
fd.minThreshold = newOutflow;
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 = () => {
handleG.removeEventListener("pointermove", onMove as EventListener);
handleG.removeEventListener("pointerup", onUp);
handleG.removeEventListener("lostpointercapture", onUp);
this.drawCanvasContent();
this.redrawEdges();
this.scheduleSave();
};
handleG.addEventListener("pointermove", onMove as EventListener);
handleG.addEventListener("pointerup", onUp);
handleG.addEventListener("lostpointercapture", onUp);
} else {
const startCapacity = fd.maxCapacity || 9000;
handleG.setPointerCapture(e.pointerId);
const label = handleG.querySelector("text");
const onMove = (ev: PointerEvent) => {
const deltaY = (ev.clientY - startY) / this.canvasZoom;
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
fd.maxCapacity = newCapacity;
if (label) label.textContent = `${this.formatDollar(newCapacity)}`;
};
const onUp = () => {
handleG.removeEventListener("pointermove", onMove as EventListener);
handleG.removeEventListener("pointerup", onUp);
handleG.removeEventListener("lostpointercapture", onUp);
this.drawCanvasContent();
this.redrawEdges();
this.scheduleSave();
};
handleG.addEventListener("pointermove", onMove as EventListener);
handleG.addEventListener("pointerup", onUp);
handleG.addEventListener("lostpointercapture", onUp);
}
}, { capture: true });
// Panning — pointerdown on SVG background // Panning — pointerdown on SVG background
svg.addEventListener("pointerdown", (e: PointerEvent) => { svg.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element; const target = e.target as Element;
@ -1608,6 +1680,10 @@ class FolkFlowsApp extends HTMLElement {
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`; allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
} }
// Flow-width bar: visual river-width proportional to flowRate
const flowBarMaxW = w - 24;
const flowBarW = Math.round(12 + Math.min(flowBarMaxW - 12, Math.sqrt(d.flowRate / 100) * (flowBarMaxW / 6)));
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})"> return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/> <rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}"> <foreignObject x="0" y="0" width="${w}" height="${h}">
@ -1624,6 +1700,7 @@ class FolkFlowsApp extends HTMLElement {
${allocBarHtml} ${allocBarHtml}
</div> </div>
</foreignObject> </foreignObject>
<rect x="${(w - flowBarW) / 2}" y="${h - 6}" width="${flowBarW}" height="4" rx="2" style="fill:#10b981;opacity:0.5"/>
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
@ -1647,7 +1724,7 @@ class FolkFlowsApp extends HTMLElement {
const taperStart = 0.80; // body tapers at 80% down const taperStart = 0.80; // body tapers at 80% down
// Drain width proportional to outflow: wider drain = more outflow // Drain width proportional to outflow: wider drain = more outflow
const outflow = d.desiredOutflow || 0; const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000); const outflowRatio = Math.min(1, outflow / 10000);
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000) const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
const insetPx = Math.round(w * taperInset); const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart); const taperY = Math.round(h * taperStart);
@ -1761,6 +1838,18 @@ class FolkFlowsApp extends HTMLElement {
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/> <rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/> <rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/> <rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
<rect x="${insetPx - 8}" y="${h - 16}" width="${w - insetPx * 2 + 16}" height="18" rx="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">
${this.formatDollar(outflow)}/mo
</text>
</g>
<g class="funnel-height-handle" data-handle="height" data-node-id="${n.id}">
<rect x="${w / 2 - 28}" y="${h + 4}" width="56" height="12" rx="5"
style="fill:var(--rs-border-strong);cursor:ns-resize;stroke:var(--rs-text-muted);stroke-width:1"/>
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none"> capacity</text>
</g>
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay"> <foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none"> <div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div> <div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
@ -2579,7 +2668,7 @@ 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 outflow = d.desiredOutflow || 0; const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000); const outflowRatio = Math.min(1, outflow / 10000);
const valveInset = 0.30 - outflowRatio * 0.18; const valveInset = 0.30 - outflowRatio * 0.18;
const valveInsetPx = Math.round(s.w * valveInset); const valveInsetPx = Math.round(s.w * valveInset);
const drainWidth = s.w - 2 * valveInsetPx; const drainWidth = s.w - 2 * valveInsetPx;
@ -2828,8 +2917,8 @@ class FolkFlowsApp extends HTMLElement {
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) * 3000) / 50) * 50; let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
newOutflow = Math.max(0, Math.min(3000, newOutflow)); newOutflow = Math.max(0, Math.min(10000, newOutflow));
fd.desiredOutflow = newOutflow; fd.desiredOutflow = newOutflow;
fd.minThreshold = newOutflow; fd.minThreshold = newOutflow;
fd.maxThreshold = newOutflow * 6; fd.maxThreshold = newOutflow * 6;
@ -2874,7 +2963,7 @@ class FolkFlowsApp extends HTMLElement {
const deltaY = (me.clientY - startY) / this.canvasZoom; const deltaY = (me.clientY - startY) / this.canvasZoom;
// Down = more capacity, up = less // 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(50000, newCapacity)); newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
fd.maxCapacity = newCapacity; fd.maxCapacity = newCapacity;
// Update label // Update label
const label = overlay.querySelector(".height-drag-label"); const label = overlay.querySelector(".height-drag-label");
@ -3855,6 +3944,7 @@ class FolkFlowsApp extends HTMLElement {
if (this.simInterval) clearInterval(this.simInterval); if (this.simInterval) clearInterval(this.simInterval);
this.simInterval = setInterval(() => { this.simInterval = setInterval(() => {
this.simTickCount++; this.simTickCount++;
this.nodes = computeInflowRates(this.nodes);
this.nodes = simulateTick(this.nodes); this.nodes = simulateTick(this.nodes);
this.accumulateNodeAnalytics(); this.accumulateNodeAnalytics();
this.updateCanvasLive(); this.updateCanvasLive();

View File

@ -3,7 +3,7 @@
* Ported from rflows-online/lib/simulation.ts. * Ported from rflows-online/lib/simulation.ts.
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types";
import { deriveThresholds } from "./types"; import { deriveThresholds } from "./types";
export interface SimulationConfig { export interface SimulationConfig {
@ -85,6 +85,31 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
return count > 0 ? sum / count : 0; return count > 0 ? sum / count : 0;
} }
/**
* Sync sourcefunnel allocations into each funnel's inflowRate.
* Funnels with no source wired keep their manual inflowRate (backward compat).
*/
export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
const computed = new Map<string, number>();
for (const n of nodes) {
if (n.type === "source") {
const d = n.data as SourceNodeData;
for (const alloc of d.targetAllocations) {
computed.set(
alloc.targetId,
(computed.get(alloc.targetId) ?? 0) + d.flowRate * (alloc.percentage / 100),
);
}
}
}
return nodes.map((n) => {
if (n.type === "funnel" && computed.has(n.id)) {
return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } };
}
return n;
});
}
export function simulateTick( export function simulateTick(
nodes: FlowNode[], nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG, config: SimulationConfig = DEFAULT_CONFIG,