From 3f19fd9c8e62ba32f187121eb2356897b96ed123 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Mar 2026 16:58:45 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20overhaul=20rFlows=20funnel=20visuals=20?= =?UTF-8?q?=E2=80=94=20bigger=20nodes,=203=20zones,=20draggable=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase funnel base size (280×250), source nodes (140-280 dynamic), outcome nodes (220×120) for better visibility - Simplify to 2 threshold lines (Min/Max) and 3 labeled zones: Critical (below min), Sufficient (min-max), Overflow (above max) - Position overflow pipes at max threshold line instead of fixed 55% - Make drain width proportional to desiredOutflow (physical metaphor) - Replace popup config panel with draggable handles for funnels: valve handle (horizontal drag → outflow rate) and height handle (vertical drag → capacity) - Increase edge stroke widths (3-28px) for more visible flow changes - Source nodes: tall/thin for small recurring, short/thick for large chunks - Keep config panel for source/outcome node types Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/folk-flows-app.ts | 358 +++++++++++++------- modules/rflows/lib/types.ts | 4 +- 2 files changed, 232 insertions(+), 130 deletions(-) diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 5be4c6d..92eaadd 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -1029,15 +1029,24 @@ class FolkFlowsApp extends HTMLElement { } private getNodeSize(n: FlowNode): { w: number; h: number } { - if (n.type === "source") return { w: 200, h: 70 }; + if (n.type === "source") { + const d = n.data as SourceNodeData; + const rate = d.flowRate || 100; + // Low rate → tall & thin (recurring drip), high rate → short & thick (large chunk) + const ratio = Math.min(1, rate / 5000); + const w = 140 + Math.round(ratio * 140); // 140–280 + const h = 110 - Math.round(ratio * 40); // 110–70 + return { w, h }; + } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const baseW = 200, baseH = 180; - const scaleRef = d.desiredOutflow || d.inflowRate; - const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3; - return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; + const baseW = 280, baseH = 250; + // Height scales with capacity (draggable), width stays fixed + 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)) }; } - return { w: 200, h: 110 }; // outcome (basin) + return { w: 220, h: 120 }; // outcome (basin) } // ─── Canvas event wiring ────────────────────────────── @@ -1571,17 +1580,20 @@ class FolkFlowsApp extends HTMLElement { private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { const d = n.data as SourceNodeData; - const x = n.position.x, y = n.position.y, w = 200, h = 70; + const s = this.getNodeSize(n); + const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const icons: Record = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" }; const icon = icons[d.sourceType] || "\u{1F4B0}"; - const stubW = 24, stubH = 20; + const bodyH = h - 20; + const stubW = 26, stubH = 20; + const isSmall = d.flowRate < 1000; return ` - - - ${icon} - ${this.esc(d.label)} - $${d.flowRate.toLocaleString()}/mo - ${this.renderAllocBar(d.targetAllocations, w, 48)} + + + ${icon} + ${this.esc(d.label)} + $${d.flowRate.toLocaleString()}/mo + ${this.renderAllocBar(d.targetAllocations, w, bodyH - 4)} ${this.renderPortsSvg(n)} `; } @@ -1590,34 +1602,51 @@ class FolkFlowsApp extends HTMLElement { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); const x = n.position.x, y = n.position.y, w = s.w, h = s.h; - const threshold = d.sufficientThreshold ?? d.maxThreshold; const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const isOverflow = d.currentValue > d.maxThreshold; const isCritical = d.currentValue < d.minThreshold; const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; const fillColor = borderColorVar; - const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained"; + const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient"; // Tank shape parameters - const r = 10; - const pipeW = 24; // overflow pipe extension from wall - const basePipeH = 20; // base pipe height - const pipeYFrac = 0.55; // pipe center at ~55% down - const taperStart = 0.75; // body tapers at 75% down - const taperInset = 0.2; + const r = 12; + const pipeW = 30; // overflow pipe extension from wall + const basePipeH = 24; // base pipe height + 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 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); const clipId = `funnel-clip-${n.id}`; - // Dynamic pipe sizing for overflow + // Interior zone boundaries + const zoneTop = 36; + const zoneBot = h - 6; + const zoneH = zoneBot - zoneTop; + + // Zone fractions for 3 zones: Critical (below min), Sufficient (min-max), Overflow (above max) + const minFrac = d.minThreshold / (d.maxCapacity || 1); + const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const criticalPct = minFrac; + const sufficientPct = maxFrac - minFrac; + const overflowPct = Math.max(0, 1 - maxFrac); + const criticalH = zoneH * criticalPct; + const sufficientH = zoneH * sufficientPct; + const overflowH = zoneH * overflowPct; + + // Pipe position at max threshold line + const maxLineY = zoneTop + zoneH * (1 - maxFrac); let pipeH = basePipeH; - let pipeY = Math.round(h * pipeYFrac) - basePipeH / 2; + let pipeY = Math.round(maxLineY - basePipeH / 2); let excessRatio = 0; if (isOverflow && d.maxCapacity > d.maxThreshold) { excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); - pipeH = basePipeH + excessRatio * 16; - pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8; + pipeH = basePipeH + Math.round(excessRatio * 20); + pipeY = Math.round(maxLineY - pipeH / 2); } // Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom @@ -1644,49 +1673,39 @@ class FolkFlowsApp extends HTMLElement { `Z`, ].join(" "); - // Interior fill zones - const zoneTop = 28; - const zoneBot = h - 4; - const zoneH = zoneBot - zoneTop; - const drainPct = d.minThreshold / (d.maxCapacity || 1); - const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); - const overflowPct = Math.max(0, 1 - drainPct - healthyPct); - const drainH = zoneH * drainPct; - const healthyH = zoneH * healthyPct; - const overflowH = zoneH * overflowPct; - // Fill level const totalFillH = zoneH * fillPct; const fillY = zoneTop + zoneH - totalFillH; - // Threshold lines (always visible) - const minFrac = d.minThreshold / (d.maxCapacity || 1); - const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1); - const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + // Threshold lines: only min and max (2 lines, 3 zones) const minLineY = zoneTop + zoneH * (1 - minFrac); - const sufLineY = zoneTop + zoneH * (1 - sufFrac); - const maxLineY = zoneTop + zoneH * (1 - maxFrac); const thresholdLines = ` - - Min - - Suf - - Overflow`; + + Min + + Max`; + + // Zone labels (centered in each zone) + const criticalMidY = zoneTop + zoneH - criticalH / 2; + const sufficientMidY = zoneTop + overflowH + sufficientH / 2; + const overflowMidY = zoneTop + overflowH / 2; + const zoneLabels = ` + ${criticalH > 20 ? `CRITICAL` : ""} + ${sufficientH > 20 ? `SUFFICIENT` : ""} + ${overflowH > 20 ? `OVERFLOW` : ""}`; // Inflow satisfaction bar - const satBarY = 40; - const satBarW = w - 40; + const satBarY = 50; + const satBarW = w - 48; const satRatio = sat ? Math.min(sat.ratio, 1) : 0; const satOverflow = sat ? sat.ratio > 1 : false; const satFillW = satBarW * satRatio; 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 glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))" - : !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : ""; - + 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 const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`; @@ -1704,36 +1723,37 @@ class FolkFlowsApp extends HTMLElement { - ${isOverflow ? `` : ""} - + ${isOverflow ? `` : ""} + - - + + ${thresholdLines} + ${zoneLabels} - - - ${inflowLabel} - ${this.esc(d.label)} - ${statusLabel} - - - ${satLabel} - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} - - - ${spendingLabel} - ${isOverflow ? `${overflowLabel} - ${overflowLabel}` : ""} + + + ${inflowLabel} + ${this.esc(d.label)} + ${statusLabel} + + + ${satLabel} + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()} + + ${this.formatDollar(outflow)}/mo ▾ + ${isOverflow ? `${overflowLabel} + ${overflowLabel}` : ""} ${this.renderPortsSvg(n)} `; } private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as OutcomeNodeData; - const x = n.position.x, y = n.position.y, w = 200, h = 110; + const s = this.getNodeSize(n); + const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0; const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)" : d.status === "blocked" ? "var(--rflows-status-blocked)" @@ -1902,8 +1922,8 @@ class FolkFlowsApp extends HTMLElement { } // Second pass: render edges with percentage-proportional widths - const MAX_EDGE_W = 16; - const MIN_EDGE_W = 1.5; + const MAX_EDGE_W = 28; + const MIN_EDGE_W = 3; let html = ""; for (const e of edges) { const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); @@ -2124,18 +2144,14 @@ class FolkFlowsApp extends HTMLElement { } if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; - // Dynamic overflow port Y for funnels — match pipe position + // Dynamic overflow port Y for funnels — match pipe at max threshold line if (node.type === "funnel" && portKind === "overflow" && def.side) { const d = node.data as FunnelNodeData; const h = s.h; - const basePipeH = 20; - let pipeY = Math.round(h * 0.55) - basePipeH / 2; - if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) { - const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); - pipeY = Math.round(h * 0.55) - basePipeH / 2 - er * 8; - } - const pipeMidY = pipeY + basePipeH / 2; - return { x: node.position.x + s.w * def.xFrac, y: node.position.y + pipeMidY }; + const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop; + const maxFrac = d.maxThreshold / (d.maxCapacity || 1); + const maxLineY = zoneTop + zoneH * (1 - maxFrac); + return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY }; } return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; @@ -2439,12 +2455,43 @@ class FolkFlowsApp extends HTMLElement { const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); overlay.classList.add("inline-edit-overlay"); - // For funnels, render threshold drag markers on the node body + // Funnels: drag handles instead of config panel if (node.type === "funnel") { - this.renderFunnelThresholdMarkers(overlay, node, s); + const d = node.data as FunnelNodeData; + const outflow = d.desiredOutflow || 0; + const outflowRatio = Math.min(1, outflow / 3000); + const valveInset = 0.30 - outflowRatio * 0.18; + const valveInsetPx = Math.round(s.w * valveInset); + const drainWidth = s.w - 2 * valveInsetPx; + + overlay.innerHTML = ` + + + ◁ ${this.formatDollar(outflow)}/mo ▷ + + + ⇕ capacity`; + + g.appendChild(overlay); + this.attachFunnelDragListeners(overlay, node, s); + + // Click-outside handler + const clickOutsideHandler = (e: Event) => { + const target = e.target as Element; + if (!target.closest(`[data-node-id="${node.id}"]`)) { + this.exitInlineEdit(); + this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true); + } + }; + setTimeout(() => { + this.shadow.addEventListener("pointerdown", clickOutsideHandler, true); + }, 100); + return; } - // Panel positioned beside the node (right side) + // Source/outcome: keep config panel const panelW = 280; const panelH = 260; const panelX = s.w + 12; @@ -2635,18 +2682,107 @@ class FolkFlowsApp extends HTMLElement { return html || '
No allocations configured
'; } + // ── Funnel drag handles (valve width + tank height) ── + + private attachFunnelDragListeners(overlay: Element, node: FlowNode, s: { w: number; h: number }) { + const valveHandle = overlay.querySelector(".valve-drag-handle"); + const heightHandle = overlay.querySelector(".height-drag-handle"); + + // Valve drag (horizontal → desiredOutflow) + if (valveHandle) { + valveHandle.addEventListener("pointerdown", (e: Event) => { + const pe = e as PointerEvent; + pe.stopPropagation(); + pe.preventDefault(); + const startX = pe.clientX; + const fd = node.data as FunnelNodeData; + const startOutflow = fd.desiredOutflow || 0; + (valveHandle as Element).setPointerCapture(pe.pointerId); + + 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)); + 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); + } + // Update label text only during drag + const label = overlay.querySelector(".valve-drag-label"); + if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`; + }; + + const onUp = () => { + valveHandle.removeEventListener("pointermove", onMove); + valveHandle.removeEventListener("pointerup", onUp); + valveHandle.removeEventListener("lostpointercapture", onUp); + // Full redraw with new shape + this.drawCanvasContent(); + this.redrawEdges(); + this.enterInlineEdit(node.id); + this.scheduleSave(); + }; + + valveHandle.addEventListener("pointermove", onMove); + valveHandle.addEventListener("pointerup", onUp); + valveHandle.addEventListener("lostpointercapture", onUp); + }); + } + + // Height drag (vertical → maxCapacity) + if (heightHandle) { + heightHandle.addEventListener("pointerdown", (e: Event) => { + const pe = e as PointerEvent; + pe.stopPropagation(); + pe.preventDefault(); + const startY = pe.clientY; + const fd = node.data as FunnelNodeData; + const startCapacity = fd.maxCapacity || 9000; + (heightHandle as Element).setPointerCapture(pe.pointerId); + + const onMove = (ev: Event) => { + const me = ev as PointerEvent; + 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)); + fd.maxCapacity = newCapacity; + // Update label + const label = overlay.querySelector(".height-drag-label"); + if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`; + }; + + const onUp = () => { + heightHandle.removeEventListener("pointermove", onMove); + heightHandle.removeEventListener("pointerup", onUp); + heightHandle.removeEventListener("lostpointercapture", onUp); + this.drawCanvasContent(); + this.redrawEdges(); + this.enterInlineEdit(node.id); + this.scheduleSave(); + }; + + heightHandle.addEventListener("pointermove", onMove); + heightHandle.addEventListener("pointerup", onUp); + heightHandle.addEventListener("lostpointercapture", onUp); + }); + } + } + // ── Funnel threshold markers (SVG on node body) ── private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { const d = node.data as FunnelNodeData; - const zoneTop = 28; - const zoneBot = s.h - 4; + const zoneTop = 36; + const zoneBot = s.h - 6; const zoneH = zoneBot - zoneTop; const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ { key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, - { key: "sufficientThreshold", value: d.sufficientThreshold ?? d.maxThreshold, color: "var(--rflows-status-thriving)", label: "Suf" }, - { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-sustained)", label: "Max" }, + { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" }, ]; for (const t of thresholds) { @@ -2815,7 +2951,7 @@ class FolkFlowsApp extends HTMLElement { const pe = e as PointerEvent; const d = node.data as FunnelNodeData; const s = this.getNodeSize(node); - const zoneH = s.h - 4 - 28; + const zoneH = s.h - 6 - 36; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); let newVal = this.inlineEditDragStartValue + deltaDollars; @@ -2877,42 +3013,8 @@ class FolkFlowsApp extends HTMLElement { private redrawNodeInlineEdit(node: FlowNode) { this.drawCanvasContent(); - const nodeLayer = this.shadow.getElementById("node-layer"); - const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; - if (!g) return; - g.querySelector(".inline-edit-overlay")?.remove(); - - const s = this.getNodeSize(node); - const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); - overlay.classList.add("inline-edit-overlay"); - - if (node.type === "funnel") { - this.renderFunnelThresholdMarkers(overlay, node, s); - } - - const panelW = Math.max(280, s.w); - const panelH = 260; - const panelX = (s.w - panelW) / 2; - const panelY = s.h + 8; - - const tabs = ["config", "analytics", "allocations"] as const; - overlay.innerHTML += ` - -
-
- ${tabs.map((t) => ``).join("")} -
-
${this.renderInlineConfigContent(node)}
-
- - - -
-
-
`; - - g.appendChild(overlay); - this.attachInlineConfigListeners(g, node); + // Re-enter inline edit to show appropriate handles/panel + this.enterInlineEdit(node.id); } private exitInlineEdit() { diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 30216fd..f7270f6 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -133,8 +133,8 @@ export const PORT_DEFS: Record = { ], funnel: [ { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, - { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, - { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, + { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, + { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, { kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, ], outcome: [