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; }
.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) */
.node-card {
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 { 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 { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
@ -215,7 +215,7 @@ class FolkFlowsApp extends HTMLElement {
const flow = doc.canvasFlows?.[this.currentFlowId];
if (flow && !this.saveTimer) {
// 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();
}
});
@ -259,7 +259,7 @@ class FolkFlowsApp extends HTMLElement {
if (!flow) return;
this.currentFlowId = flow.id;
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.restoreViewport(flowId);
this.loading = false;
@ -1042,14 +1042,17 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } {
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") {
const d = n.data as FunnelNodeData;
const baseW = 280, baseH = 250;
const hRef = d.maxCapacity || 9000;
const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35;
return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) };
const baseW = 280;
const cap = d.maxCapacity || 9000;
const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
return { w: baseW, h: Math.max(200, h) };
}
return { w: 220, h: 180 }; // outcome card
}
@ -1083,6 +1086,75 @@ class FolkFlowsApp extends HTMLElement {
this.updateCanvasTransform();
}, { 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
svg.addEventListener("pointerdown", (e: PointerEvent) => {
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>`;
}
// 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})">
<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}">
@ -1624,6 +1700,7 @@ class FolkFlowsApp extends HTMLElement {
${allocBarHtml}
</div>
</foreignObject>
<rect x="${(w - flowBarW) / 2}" y="${h - 6}" width="${flowBarW}" height="4" rx="2" style="fill:#10b981;opacity:0.5"/>
${this.renderPortsSvg(n)}
</g>`;
}
@ -1647,7 +1724,7 @@ class FolkFlowsApp extends HTMLElement {
const taperStart = 0.80; // body tapers at 80% down
// Drain width proportional to outflow: wider drain = more outflow
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 insetPx = Math.round(w * taperInset);
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="${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"/>
<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">
<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>
@ -2579,7 +2668,7 @@ class FolkFlowsApp extends HTMLElement {
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
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 valveInsetPx = Math.round(s.w * valveInset);
const drainWidth = s.w - 2 * valveInsetPx;
@ -2828,8 +2917,8 @@ class FolkFlowsApp extends HTMLElement {
const onMove = (ev: Event) => {
const me = ev as PointerEvent;
const deltaX = (me.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 3000) / 50) * 50;
newOutflow = Math.max(0, Math.min(3000, newOutflow));
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;
@ -2874,7 +2963,7 @@ class FolkFlowsApp extends HTMLElement {
const deltaY = (me.clientY - startY) / this.canvasZoom;
// Down = more capacity, up = less
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;
// Update label
const label = overlay.querySelector(".height-drag-label");
@ -3855,6 +3944,7 @@ class FolkFlowsApp extends HTMLElement {
if (this.simInterval) clearInterval(this.simInterval);
this.simInterval = setInterval(() => {
this.simTickCount++;
this.nodes = computeInflowRates(this.nodes);
this.nodes = simulateTick(this.nodes);
this.accumulateNodeAnalytics();
this.updateCanvasLive();

View File

@ -3,7 +3,7 @@
* 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";
export interface SimulationConfig {
@ -85,6 +85,31 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
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(
nodes: FlowNode[],
config: SimulationConfig = DEFAULT_CONFIG,