diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index b247013..b19dade 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -283,11 +283,14 @@ .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-drain-knob, .funnel-height-handle { opacity: 0; transition: opacity 0.2s; } +.flow-node:hover .funnel-drain-knob, +.flow-node:hover .funnel-height-handle { opacity: 0.85; } +.funnel-drain-knob:hover { opacity: 1 !important; } .funnel-height-handle:hover { opacity: 1 !important; } +.drain-knob { cursor: ew-resize; transition: filter 0.15s; } +.drain-knob:hover { filter: brightness(1.15); } +.drain-handle-group { transition: transform 0.2s ease; } /* HTML card nodes (foreignObject) */ .node-card { @@ -526,12 +529,6 @@ .satisfaction-bar-bg { opacity: 0.3; } .satisfaction-bar-fill { transition: width 0.3s ease; } -/* Split controls — proportional allocation sliders */ -.split-control { pointer-events: all; } -.split-seg { transition: width 50ms ease-out; } -.split-divider { cursor: ew-resize; } -.split-divider:hover rect { fill: #6366f1; stroke: #818cf8; } -.split-divider:active rect { fill: #4f46e5; } /* ── Node detail modals ──────────────────────────────── */ .flows-modal-backdrop { diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 88489ac..fd425a1 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -1254,7 +1254,7 @@ class FolkFlowsApp extends HTMLElement { // 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 valveG = target.closest(".funnel-drain-knob") as SVGGElement | null; const heightG = target.closest(".funnel-height-handle") as SVGGElement | null; const handleG = valveG || heightG; if (!handleG) return; @@ -1271,13 +1271,20 @@ class FolkFlowsApp extends HTMLElement { if (valveG) { const startDrain = fd.drainRate || 0; handleG.setPointerCapture(e.pointerId); - const label = handleG.querySelector("text"); + const knobLabel = handleG.querySelector(".drain-knob-label") as SVGTextElement | null; + const handleGroup = handleG.querySelector(".drain-handle-group") as SVGGElement | null; + const knobCircle = handleG.querySelector(".drain-knob") as SVGCircleElement | null; + const cx = knobCircle ? parseFloat(knobCircle.getAttribute("cx")!) : s.w / 2; + const cy = knobCircle ? parseFloat(knobCircle.getAttribute("cy")!) : s.h - 12; const onMove = (ev: PointerEvent) => { const deltaX = (ev.clientX - startX) / this.canvasZoom; let newDrain = Math.round((startDrain + (deltaX / s.w) * 10000) / 50) * 50; newDrain = Math.max(0, Math.min(10000, newDrain)); fd.drainRate = newDrain; - if (label) label.textContent = `◁ ${this.formatDollar(newDrain)}/mo ▷`; + // Live rotation update + const angle = Math.min(90, (newDrain / 10000) * 90); + if (handleGroup) handleGroup.setAttribute("transform", `rotate(${-90 + angle},${cx},${cy})`); + if (knobLabel) knobLabel.textContent = `${this.formatDollar(newDrain)}/mo`; }; const onUp = () => { handleG.removeEventListener("pointermove", onMove as EventListener); @@ -1611,32 +1618,6 @@ class FolkFlowsApp extends HTMLElement { }); } - // Split control drag (delegated on node layer) - const nodeLayerForSplit = this.shadow.getElementById("node-layer"); - if (nodeLayerForSplit) { - nodeLayerForSplit.addEventListener("pointerdown", (e: PointerEvent) => { - const divider = (e.target as Element).closest(".split-divider") as SVGGElement | null; - if (!divider) return; - e.stopPropagation(); - e.preventDefault(); - const nodeId = divider.dataset.nodeId!; - const allocType = divider.dataset.allocType!; - const dividerIdx = parseInt(divider.dataset.dividerIdx!, 10); - - // Capture starting percentages - const allocs = this.getSplitAllocs(nodeId, allocType); - if (!allocs || allocs.length < 2) return; - - this._splitDragging = true; - this._splitDragNodeId = nodeId; - this._splitDragAllocType = allocType; - this._splitDragDividerIdx = dividerIdx; - this._splitDragStartX = e.clientX; - this._splitDragStartPcts = allocs.map(a => a.percentage); - (e.target as Element).setPointerCapture?.(e.pointerId); - }); - } - // Timeline minimize const timelineMin = this.shadow.getElementById("timeline-minimize"); if (timelineMin) { @@ -1912,10 +1893,7 @@ class FolkFlowsApp extends HTMLElement { const streamY = nozzleEndY + nozzleBotW; const streamH = h - streamY; - // Split control replaces old allocation bar - const allocBar = d.targetAllocations && d.targetAllocations.length >= 2 - ? this.renderSplitControl(n.id, "source", d.targetAllocations, w / 2, h - 8, w - 40) - : ""; + const allocBar = ""; return ` @@ -1972,6 +1950,19 @@ class FolkFlowsApp extends HTMLElement { return `M ${pts.join(" L ")} Z`; } + /** Render a rotary drain knob SVG group matching the source valve style */ + private renderDrainKnob(nodeId: string, cx: number, cy: number, drainRate: number): string { + const r = 18; + const angle = Math.min(90, (drainRate / 10000) * 90); + return ` + + + + + ${this.formatDollar(drainRate)}/mo + `; + } + private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { const d = n.data as FunnelNodeData; const s = this.getNodeSize(n); @@ -2157,24 +2148,8 @@ class FolkFlowsApp extends HTMLElement { ${overflowSpill} - - - - - ◁ ${this.formatDollar(drain)}/mo ▷ - - - - ${(() => { const spW = fwFunnel ? Math.max(4, Math.round(fwFunnel.spendingWidthPx)) : 4; return ``; })()} - - ${d.spendingAllocations.length >= 2 - ? this.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60)) - : ""} - - ${d.overflowAllocations.length >= 2 - ? this.renderSplitControl(n.id, "overflow", d.overflowAllocations, w / 2, pipeY - 12, w - 40) - : ""} + + ${this.renderDrainKnob(n.id, w / 2, h - 12, drain)} @@ -2196,8 +2171,6 @@ class FolkFlowsApp extends HTMLElement { ${overflowH > 20 ? `OVERFLOW` : ""} $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()} - - ${this.formatDollar(drain)}/mo \u25BE ${isOverflow ? `${overflowLabel} ${overflowLabel}` : ""} @@ -3494,14 +3467,18 @@ class FolkFlowsApp extends HTMLElement { // ── Allocations tab ── private renderInlineAllocTab(node: FlowNode): string { - const renderRows = (title: string, allocs: { targetId: string; percentage: number; color: string }[]) => { + const renderRows = (title: string, allocType: string, allocs: { targetId: string; percentage: number; color: string }[]) => { if (!allocs || allocs.length === 0) return ""; let html = `
${title}
`; - for (const a of allocs) { - html += `
+ for (let i = 0; i < allocs.length; i++) { + const a = allocs[i]; + html += `
- ${this.esc(this.getNodeLabel(a.targetId))} - ${a.percentage}% + ${this.esc(this.getNodeLabel(a.targetId))} + ${a.percentage}% + ${allocs.length >= 2 ? `` : ""}
`; } return html; @@ -3509,17 +3486,17 @@ class FolkFlowsApp extends HTMLElement { if (node.type === "source") { const d = node.data as SourceNodeData; - const html = renderRows("Target Allocations", d.targetAllocations); + const html = renderRows("Target Allocations", "source", d.targetAllocations); return html || '
No allocations configured
'; } if (node.type === "funnel") { const d = node.data as FunnelNodeData; - let html = renderRows("Spending Allocations", d.spendingAllocations); - html += renderRows("Overflow Allocations", d.overflowAllocations); + let html = renderRows("Spending Allocations", "spending", d.spendingAllocations); + html += renderRows("Overflow Allocations", "overflow", d.overflowAllocations); return html || '
No allocations configured
'; } const od = node.data as OutcomeNodeData; - const html = renderRows("Overflow Allocations", od.overflowAllocations || []); + const html = renderRows("Overflow Allocations", "overflow", od.overflowAllocations || []); return html || '
No allocations configured
'; } @@ -3785,6 +3762,61 @@ class FolkFlowsApp extends HTMLElement { this.scheduleSave(); }); }); + + // Allocation range sliders — proportional rebalancing + overlay.querySelectorAll(".icp-alloc-range").forEach((el) => { + const input = el as HTMLInputElement; + input.addEventListener("input", () => { + const allocType = input.dataset.allocType!; + const idx = parseInt(input.dataset.allocIdx!, 10); + const allocs = this.getSplitAllocs(node.id, allocType); + if (!allocs || allocs.length < 2) return; + const newPct = parseInt(input.value, 10); + const oldPct = allocs[idx].percentage; + const delta = newPct - oldPct; + if (delta === 0) return; + + // Proportional rebalancing of siblings + const MIN_PCT = 1; + const othersTotal = allocs.reduce((s, a, i) => i === idx ? s : s + a.percentage, 0); + allocs[idx].percentage = newPct; + const remaining = 100 - newPct; + + for (let i = 0; i < allocs.length; i++) { + if (i === idx) continue; + if (othersTotal > 0) { + allocs[i].percentage = Math.max(MIN_PCT, Math.round((allocs[i].percentage / othersTotal) * remaining)); + } else { + allocs[i].percentage = Math.max(MIN_PCT, Math.round(remaining / (allocs.length - 1))); + } + } + // Normalize to exactly 100 + const total = allocs.reduce((s, a) => s + a.percentage, 0); + if (total !== 100 && allocs.length > 1) { + const lastOther = allocs.findIndex((_, i) => i !== idx); + if (lastOther >= 0) allocs[lastOther].percentage += 100 - total; + } + + // Update sibling labels + slider values in the panel + const row = input.closest(".icp-alloc-row"); + const pctSpan = row?.querySelector(".icp-alloc-pct"); + if (pctSpan) pctSpan.textContent = `${newPct}%`; + const allRows = overlay.querySelectorAll(`.icp-alloc-range[data-alloc-type="${allocType}"]`); + allRows.forEach((sibEl) => { + const sib = sibEl as HTMLInputElement; + const si = parseInt(sib.dataset.allocIdx!, 10); + if (si === idx) return; + sib.value = String(allocs[si].percentage); + const sibRow = sib.closest(".icp-alloc-row"); + const sibPct = sibRow?.querySelector(".icp-alloc-pct"); + if (sibPct) sibPct.textContent = `${allocs[si].percentage}%`; + }); + + this.redrawNodeOnly(node); + this.redrawEdges(); + this.scheduleSave(); + }); + }); } private attachThresholdDragListeners(overlay: Element, node: FlowNode) {