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:
parent
8635b54800
commit
c35f39380e
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue