From eeae7d2aa1347ad9feddce8e87aaa824067afa8d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 6 Mar 2026 11:52:09 -0800 Subject: [PATCH] =?UTF-8?q?feat(rflows):=20close=20source=E2=86=92funnel?= =?UTF-8?q?=20flow=20gap,=20interactive=20sizing=20+=20drag=20handles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/rflows/components/flows.css | 7 ++ modules/rflows/components/folk-flows-app.ts | 116 +++++++++++++++++--- modules/rflows/lib/simulation.ts | 27 ++++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 593b923..6847511 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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; diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 89eafad..4b22c31 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -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 = `
${segs}
`; } + // 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 ` @@ -1624,6 +1700,7 @@ class FolkFlowsApp extends HTMLElement { ${allocBarHtml} + ${this.renderPortsSvg(n)} `; } @@ -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 { + + + + ◁ ${this.formatDollar(outflow)}/mo ▷ + + + + + ⇕ capacity +
\u2193 ${inflowLabel}
@@ -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(); diff --git a/modules/rflows/lib/simulation.ts b/modules/rflows/lib/simulation.ts index b0352e5..d8e400f 100644 --- a/modules/rflows/lib/simulation.ts +++ b/modules/rflows/lib/simulation.ts @@ -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 source→funnel 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(); + 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,