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 = `