feat(rflows): replace drain valve bar with rotary knob, move allocations to panel

- Add renderDrainKnob() with rotating handle matching source valve style
- Remove rectangular ◁ $/mo ▷ drag bar and split control overlays from canvas
- Add editable range sliders in inline config Allocations tab
- Rewire drag handler for live knob rotation during drain rate adjustment
- Clean up dead split-divider CSS and event listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 15:38:59 -07:00
parent 8635b54800
commit c35f39380e
2 changed files with 101 additions and 72 deletions

View File

@ -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 {

View File

@ -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 `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
@ -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 `<g class="funnel-drain-knob" data-handle="valve" data-node-id="${nodeId}">
<circle class="drain-knob" cx="${cx}" cy="${cy}" r="${r}" fill="var(--rflows-label-spending)" stroke="var(--rs-bg-surface)" stroke-width="1.5"/>
<g class="drain-handle-group" transform="rotate(${-90 + angle},${cx},${cy})">
<rect x="${cx - 3}" y="${cy - r - 4}" width="6" height="${r + 4}" rx="3" fill="var(--rs-bg-surface)" opacity="0.8"/>
</g>
<text class="drain-knob-label" x="${cx}" y="${cy + 28}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(drainRate)}/mo</text>
</g>`;
}
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}
<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}/>
<!-- Drain valve handle at spout -->
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
<rect x="${drainInset - 8}" y="${h - 16}" width="${drainW + 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(drain)}/mo
</text>
</g>
<!-- Spending drain pipe stub -->
${(() => { const spW = fwFunnel ? Math.max(4, Math.round(fwFunnel.spendingWidthPx)) : 4; return `<rect x="${(w / 2) - spW / 2}" y="${h}" width="${spW}" height="18" rx="${Math.min(4, spW / 2)}" fill="var(--rflows-edge-spending)" opacity="0.6"/>`; })()}
<!-- Spending split control at drain spout -->
${d.spendingAllocations.length >= 2
? this.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60))
: ""}
<!-- Overflow split control at pipe area -->
${d.overflowAllocations.length >= 2
? this.renderSplitControl(n.id, "overflow", d.overflowAllocations, w / 2, pipeY - 12, w - 40)
: ""}
<!-- Rotary drain knob at spout -->
${this.renderDrainKnob(n.id, w / 2, h - 12, drain)}
<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"/>
@ -2196,8 +2171,6 @@ class FolkFlowsApp extends HTMLElement {
${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">OVERFLOW</text>` : ""}
<!-- Value text -->
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="13" font-weight="500" pointer-events="none">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.overflowThreshold).toLocaleString()}</text>
<!-- Drain rate label -->
<text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(drain)}/mo \u25BE</text>
<!-- Overflow labels at pipe positions -->
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""}
@ -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 = `<div style="font-size:10px;color:var(--rs-text-muted);text-transform:uppercase;font-weight:600;margin-bottom:4px">${title}</div>`;
for (const a of allocs) {
html += `<div class="icp-alloc-row">
for (let i = 0; i < allocs.length; i++) {
const a = allocs[i];
html += `<div class="icp-alloc-row" style="flex-wrap:wrap;gap:4px">
<span class="icp-alloc-dot" style="background:${a.color}"></span>
<span style="flex:1">${this.esc(this.getNodeLabel(a.targetId))}</span>
<span style="font-weight:600">${a.percentage}%</span>
<span style="flex:1;min-width:60px">${this.esc(this.getNodeLabel(a.targetId))}</span>
<span class="icp-alloc-pct" style="font-weight:600;min-width:36px;text-align:right">${a.percentage}%</span>
${allocs.length >= 2 ? `<input type="range" class="icp-alloc-range" min="1" max="99" value="${a.percentage}"
data-alloc-type="${allocType}" data-alloc-idx="${i}" data-node-id="${node.id}"
style="width:100%;accent-color:${a.color};height:16px;margin:2px 0 4px"/>` : ""}
</div>`;
}
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 || '<div class="icp-empty">No allocations configured</div>';
}
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 || '<div class="icp-empty">No allocations configured</div>';
}
const od = node.data as OutcomeNodeData;
const html = renderRows("Overflow Allocations", od.overflowAllocations || []);
const html = renderRows("Overflow Allocations", "overflow", od.overflowAllocations || []);
return html || '<div class="icp-empty">No allocations configured</div>';
}
@ -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) {