diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 5dfe96f..d7a4ebe 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -525,6 +525,13 @@ .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 { position: fixed; inset: 0; z-index: 50; @@ -662,8 +669,8 @@ /* Inline edit overlay container */ .inline-edit-overlay { pointer-events: all; } -/* Funnel overflow pipe (vessel metaphor) */ -.funnel-pipe { transition: fill 0.3s, height 0.3s, y 0.3s; } +/* Funnel overflow pipe (vessel metaphor) — fixed height, animate opacity + fill */ +.funnel-pipe { transition: opacity 0.2s ease, fill 0.2s ease; } .funnel-pipe--active { fill: #10b981; } /* Threshold lines inside tank */ diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 6cfa126..a9d0373 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -116,6 +116,17 @@ class FolkFlowsApp extends HTMLElement { private draggingEdgeKey: string | null = null; private edgeDragPointerId: number | null = null; + // Sankey flow width pre-pass results + private _currentFlowWidths: Map = new Map(); + + // Split control drag state + private _splitDragging = false; + private _splitDragNodeId: string | null = null; + private _splitDragAllocType: string | null = null; + private _splitDragDividerIdx = 0; + private _splitDragStartX = 0; + private _splitDragStartPcts: number[] = []; + // Source purchase modal state private sourceModalNodeId: string | null = null; @@ -1308,6 +1319,11 @@ class FolkFlowsApp extends HTMLElement { const DRAG_THRESHOLD = 5; this._boundPointerMove = (e: PointerEvent) => { + // Split control drag + if (this._splitDragging) { + this.handleSplitDragMove(e.clientX); + return; + } if (this.wiringActive && this.wiringDragging) { this.wiringPointerX = e.clientX; this.wiringPointerY = e.clientY; @@ -1355,6 +1371,11 @@ class FolkFlowsApp extends HTMLElement { } }; this._boundPointerUp = (e: PointerEvent) => { + // Split control drag end + if (this._splitDragging) { + this.handleSplitDragEnd(); + return; + } if (this.wiringActive && this.wiringDragging) { // Hit-test: did we release on a compatible input port? const el = this.shadow.elementFromPoint(e.clientX, e.clientY); @@ -1563,25 +1584,38 @@ class FolkFlowsApp extends HTMLElement { }); } - // Edge +/- buttons (delegated) + // 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); + }); + } + + // Edge layer — edge selection + drag handles const edgeLayer = this.shadow.getElementById("edge-layer"); if (edgeLayer) { - edgeLayer.addEventListener("click", (e: Event) => { - const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null; - if (!btn) return; - e.stopPropagation(); - const action = btn.dataset.edgeAction; // "inc" or "dec" - const fromId = btn.dataset.edgeFrom!; - const toId = btn.dataset.edgeTo!; - const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source"; - this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5); - }); - - // Edge selection — click on edge path (not buttons) + // Edge selection — click on edge path edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as Element; - // Ignore clicks on +/- buttons or drag handle - if (target.closest("[data-edge-action]")) return; if (target.closest(".edge-drag-handle")) return; const edgeGroup = target.closest(".edge-group") as SVGGElement | null; @@ -1601,7 +1635,6 @@ class FolkFlowsApp extends HTMLElement { // Double-click edge → open source node editor edgeLayer.addEventListener("dblclick", (e: Event) => { const target = e.target as Element; - if (target.closest("[data-edge-action]")) return; if (target.closest(".edge-drag-handle")) return; const edgeGroup = target.closest(".edge-group") as SVGGElement | null; @@ -1834,26 +1867,17 @@ class FolkFlowsApp extends HTMLElement { const nozzleBotW = 7; // half-width at end const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`; - // Stream: rect from nozzle tip downward, width proportional to sqrt(flowRate/100) - const streamW = Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5)); + // Stream: rect from nozzle tip downward, width from Sankey pre-pass + const fw = this._currentFlowWidths.get(n.id); + const streamW = fw ? Math.max(4, Math.round(fw.outflowWidthPx * 0.4)) : Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5)); const streamX = nozzleEndX; const streamY = nozzleEndY + nozzleBotW; const streamH = h - streamY; - // Allocation bar - let allocBar = ""; - if (d.targetAllocations && d.targetAllocations.length > 0) { - const barY = h - 8; - const barW = w - 40; - const barX = 20; - let cx = barX; - allocBar = d.targetAllocations.map(a => { - const segW = (a.percentage / 100) * barW; - const rect = ``; - cx += segW + 1; - return rect; - }).join(""); - } + // 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) + : ""; return ` @@ -1939,14 +1963,12 @@ class FolkFlowsApp extends HTMLElement { const minFrac = d.minThreshold / (d.maxCapacity || 1); const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const maxLineY = zoneTop + zoneH * (1 - maxFrac); - let pipeH = basePipeH; - 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 + Math.round(excessRatio * 16); - pipeY = Math.round(maxLineY - pipeH / 2); - } + // Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps + const pipeH = basePipeH; + const pipeY = Math.round(maxLineY - basePipeH / 2); + const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold + ? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)) + : 0; // Wall inset at pipe Y position for pipe attachment const pipeYFrac = (maxLineY - zoneTop) / zoneH; @@ -1964,6 +1986,31 @@ class FolkFlowsApp extends HTMLElement { rightWall.push(`${w - inset},${py}`); } + // Compute interpolated wall insets at exact pipe boundaries to avoid path discontinuities + const pipeTopFrac = Math.max(0, (pipeY - zoneTop) / zoneH); + const pipeBotFrac = Math.min(1, (pipeY + pipeH - zoneTop) / zoneH); + const rightInsetAtPipeTop = this.vesselWallInset(pipeTopFrac, taperAtBottom); + const rightInsetAtPipeBot = this.vesselWallInset(pipeBotFrac, taperAtBottom); + + // Right wall segments below pipe bottom + const rightWallBelow: string[] = []; + // Add interpolated point at exact pipe bottom + rightWallBelow.push(`${w - rightInsetAtPipeBot},${pipeY + pipeH}`); + for (let i = 0; i <= steps; i++) { + const py = zoneTop + zoneH * (i / steps); + if (py > pipeY + pipeH) rightWallBelow.push(rightWall[i]); + } + + // Left wall segments below pipe bottom (reversed for upward traversal) + const leftWallBelow: string[] = []; + for (let i = 0; i <= steps; i++) { + const py = zoneTop + zoneH * (i / steps); + if (py > pipeY + pipeH) leftWallBelow.push(leftWall[i]); + } + // Add interpolated point at exact pipe bottom + leftWallBelow.push(`${this.vesselWallInset(pipeBotFrac, taperAtBottom)},${pipeY + pipeH}`); + leftWallBelow.reverse(); + const vesselPath = [ `M ${r},0`, `L ${w - r},0`, @@ -1972,12 +2019,8 @@ class FolkFlowsApp extends HTMLElement { `L ${w},${pipeY}`, `L ${w + pipeW},${pipeY}`, `L ${w + pipeW},${pipeY + pipeH}`, - `L ${w},${pipeY + pipeH}`, - // Continue right wall tapering down - ...rightWall.filter((_, i) => { - const py = zoneTop + zoneH * (i / steps); - return py > pipeY + pipeH; - }).map(p => `L ${p}`), + // Continue right wall tapering from interpolated pipe bottom point + ...rightWallBelow.map(p => `L ${p}`), // Bottom: narrow drain spout with rounded corners `L ${w - taperAtBottom + r},${zoneBot}`, `Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`, @@ -1985,13 +2028,9 @@ class FolkFlowsApp extends HTMLElement { `L ${taperAtBottom},${h}`, `L ${taperAtBottom},${h - r}`, `Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`, - // Left wall tapering up - ...leftWall.filter((_, i) => { - const py = zoneTop + zoneH * (i / steps); - return py > pipeY + pipeH; - }).reverse().map(p => `L ${p}`), + // Left wall tapering up from interpolated pipe bottom point + ...leftWallBelow.map(p => `L ${p}`), // Left pipe notch - `L 0,${pipeY + pipeH}`, `L ${-pipeW},${pipeY + pipeH}`, `L ${-pipeW},${pipeY}`, `L 0,${pipeY}`, @@ -2036,6 +2075,15 @@ class FolkFlowsApp extends HTMLElement { ` : ""; + // Inflow pipe indicator (Sankey-consistent) + const fwFunnel = this._currentFlowWidths.get(n.id); + const inflowPipeW = fwFunnel ? Math.min(w - 20, Math.round(fwFunnel.inflowWidthPx)) : 0; + const inflowFillRatio = fwFunnel ? fwFunnel.inflowFillRatio : 0; + const inflowPipeX = (w - inflowPipeW) / 2; + const inflowPipeIndicator = inflowPipeW > 0 ? ` + + ` : ""; + // Inflow satisfaction bar const satBarY = 50; const satBarW = w - 48; @@ -2074,6 +2122,7 @@ class FolkFlowsApp extends HTMLElement { ${shimmerLine} ${thresholdLines} + ${inflowPipeIndicator} @@ -2088,28 +2137,41 @@ class FolkFlowsApp extends HTMLElement { ◁ ${this.formatDollar(outflow)}/mo ▷ + + ${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) + : ""} ⇕ capacity - -
-
\u2193 ${inflowLabel}
-
- ${this.esc(d.label)} - ${statusLabel} -
-
${satLabel}
- ${criticalH > 20 ? `
CRITICAL
` : ""} - ${sufficientH > 20 ? `
SUFFICIENT
` : ""} - ${overflowH > 20 ? `
OVERFLOW
` : ""} -
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}
-
${this.formatDollar(outflow)}/mo \u25BE
- ${isOverflow ? `
${overflowLabel}
-
${overflowLabel}
` : ""} + + \u2193 ${inflowLabel} + + +
+ ${this.esc(d.label)} + ${statusLabel}
+ + ${satLabel} + + ${criticalH > 20 ? `CRITICAL` : ""} + ${sufficientH > 20 ? `SUFFICIENT` : ""} + ${overflowH > 20 ? `OVERFLOW` : ""} + + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()} + + ${this.formatDollar(outflow)}/mo \u25BE + + ${isOverflow ? `${overflowLabel} + ${overflowLabel}` : ""} ${this.renderPortsSvg(n)} `; } @@ -2209,6 +2271,45 @@ class FolkFlowsApp extends HTMLElement { return bar; } + /** Render a proportional split control — draggable stacked bar showing allocation ratios */ + private renderSplitControl( + nodeId: string, allocType: string, + allocs: { targetId: string; percentage: number; color: string }[], + cx: number, cy: number, trackW: number, + ): string { + if (!allocs || allocs.length < 2) return ""; + const trackH = 14; + const trackX = cx - trackW / 2; + const trackY = cy - trackH / 2; + + let svg = ``; + svg += ``; + + // Segments + let segX = trackX; + for (let i = 0; i < allocs.length; i++) { + const a = allocs[i]; + const segW = trackW * (a.percentage / 100); + svg += ``; + segX += segW; + } + + // Dividers between segments + let divX = trackX; + for (let i = 0; i < allocs.length - 1; i++) { + divX += trackW * (allocs[i].percentage / 100); + const leftPct = Math.round(allocs[i].percentage); + const rightPct = Math.round(allocs[i + 1].percentage); + svg += ` + + ${leftPct}% | ${rightPct}% + `; + } + + svg += ``; + return svg; + } + // ─── Edge rendering ─────────────────────────────────── private formatDollar(amount: number): string { @@ -2217,6 +2318,74 @@ class FolkFlowsApp extends HTMLElement { return `$${Math.round(amount)}`; } + /** Pre-pass: compute per-node flow totals and Sankey-consistent pixel widths */ + private computeFlowWidths(): void { + const MIN_PX = 8, MAX_PX = 80; + const nodeFlows = new Map(); + + // Initialize all nodes + for (const n of this.nodes) nodeFlows.set(n.id, { totalOutflow: 0, totalInflow: 0 }); + + // Sum outflow/inflow per node (mirrors edge-building logic) + for (const n of this.nodes) { + if (n.type === "source") { + const d = n.data as SourceNodeData; + for (const alloc of d.targetAllocations) { + const flow = d.flowRate * (alloc.percentage / 100); + nodeFlows.get(n.id)!.totalOutflow += flow; + if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; + } + } + if (n.type === "funnel") { + const d = n.data as FunnelNodeData; + const excess = Math.max(0, d.currentValue - d.maxThreshold); + for (const alloc of d.overflowAllocations) { + const flow = excess * (alloc.percentage / 100); + nodeFlows.get(n.id)!.totalOutflow += flow; + if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; + } + let rateMultiplier: number; + if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8; + else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5; + else rateMultiplier = 0.1; + const drain = d.inflowRate * rateMultiplier; + for (const alloc of d.spendingAllocations) { + const flow = drain * (alloc.percentage / 100); + nodeFlows.get(n.id)!.totalOutflow += flow; + if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; + } + } + if (n.type === "outcome") { + const d = n.data as OutcomeNodeData; + const excess = Math.max(0, d.fundingReceived - d.fundingTarget); + for (const alloc of (d.overflowAllocations || [])) { + const flow = excess * (alloc.percentage / 100); + nodeFlows.get(n.id)!.totalOutflow += flow; + if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow; + } + } + } + + // Find global max outflow for scaling + let globalMaxFlow = 0; + for (const [, v] of nodeFlows) globalMaxFlow = Math.max(globalMaxFlow, v.totalOutflow); + if (globalMaxFlow === 0) globalMaxFlow = 1; + + // Compute pixel widths + this._currentFlowWidths = new Map(); + for (const n of this.nodes) { + const nf = nodeFlows.get(n.id)!; + const outflowWidthPx = nf.totalOutflow > 0 ? MIN_PX + (nf.totalOutflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX; + // Inflow: compute needed rate for funnels/outcomes + let neededInflow = 0; + if (n.type === "funnel") neededInflow = (n.data as FunnelNodeData).inflowRate || 1; + else if (n.type === "outcome") neededInflow = Math.max((n.data as OutcomeNodeData).fundingTarget, 1); + const inflowFillRatio = neededInflow > 0 ? Math.min(nf.totalInflow / neededInflow, 1) : 0; + const inflowWidthPx = nf.totalInflow > 0 ? MIN_PX + (nf.totalInflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX; + this._currentFlowWidths.set(n.id, { totalOutflow: nf.totalOutflow, totalInflow: nf.totalInflow, outflowWidthPx, inflowWidthPx, inflowFillRatio }); + } + } + private renderAllEdges(): string { // First pass: compute actual dollar flow per edge interface EdgeInfo { @@ -2308,16 +2477,26 @@ class FolkFlowsApp extends HTMLElement { } } - // Second pass: render edges with flow-value-relative widths (Sankey-style) - const MAX_EDGE_W = 28; + // Pre-compute Sankey-consistent flow widths + this.computeFlowWidths(); + + // Second pass: render edges with per-node proportional widths (Sankey-consistent) const MIN_EDGE_W = 3; - const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1); let html = ""; for (const e of edges) { const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); const to = this.getPortPosition(e.toNode, "inflow"); const isGhost = e.flowAmount === 0; - const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W); + // Per-node proportional width: edge width = node's outflowWidthPx * (edgeFlow / totalOutflow) + const nodeWidths = this._currentFlowWidths.get(e.fromId); + let strokeW: number; + if (isGhost) { + strokeW = 1; + } else if (nodeWidths && nodeWidths.totalOutflow > 0) { + strokeW = Math.max(MIN_EDGE_W, nodeWidths.outflowWidthPx * (e.flowAmount / nodeWidths.totalOutflow)); + } else { + strokeW = MIN_EDGE_W; + } const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`; html += this.renderEdgePath( from.x, from.y, to.x, to.y, @@ -2381,15 +2560,7 @@ class FolkFlowsApp extends HTMLElement { - ${label} - - - - - - - + - + ${label} `; } @@ -2397,8 +2568,8 @@ class FolkFlowsApp extends HTMLElement { const overflowMul = dashed ? 1.3 : 1; const finalStrokeW = strokeW * overflowMul; const animClass = dashed ? "edge-path-overflow" : "edge-path-animated"; - // Wider label box to fit dollar amounts - const labelW = Math.max(68, label.length * 7 + 36); + // Label box — read-only, no +/- buttons (splits controlled at nodes) + const labelW = Math.max(68, label.length * 7 + 12); const halfW = labelW / 2; // Drag handle at midpoint const dragHandle = ``; @@ -2413,14 +2584,6 @@ class FolkFlowsApp extends HTMLElement { ${label} - - - - - - - + - `; } @@ -2853,6 +3016,111 @@ class FolkFlowsApp extends HTMLElement { // ─── Allocation adjustment ──────────────────────────── + /** Get the allocation array for a node + allocType combo */ + private getSplitAllocs(nodeId: string, allocType: string): { targetId: string; percentage: number; color: string }[] | null { + const node = this.nodes.find(n => n.id === nodeId); + if (!node) return null; + if (allocType === "source" && node.type === "source") return (node.data as SourceNodeData).targetAllocations; + if (allocType === "spending" && node.type === "funnel") return (node.data as FunnelNodeData).spendingAllocations; + if (allocType === "overflow") { + if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations; + if (node.type === "outcome") return (node.data as OutcomeNodeData).overflowAllocations || []; + } + return null; + } + + /** Handle split divider drag — redistribute percentages between adjacent segments */ + private handleSplitDragMove(clientX: number) { + if (!this._splitDragging || !this._splitDragNodeId) return; + const allocs = this.getSplitAllocs(this._splitDragNodeId, this._splitDragAllocType!); + if (!allocs || allocs.length < 2) return; + + const idx = this._splitDragDividerIdx; + const startPcts = this._splitDragStartPcts; + const MIN_PCT = 5; + + // Compute delta as percentage of track width (approximate from zoom-adjusted pixels) + const deltaX = clientX - this._splitDragStartX; + const trackW = 200; // approximate track width in pixels + const deltaPct = (deltaX / (trackW * this.canvasZoom)) * 100; + + // Redistribute between segment[idx] and segment[idx+1] + const leftOrig = startPcts[idx]; + const rightOrig = startPcts[idx + 1]; + const combined = leftOrig + rightOrig; + let newLeft = Math.round(leftOrig + deltaPct); + newLeft = Math.max(MIN_PCT, Math.min(combined - MIN_PCT, newLeft)); + const newRight = combined - newLeft; + + allocs[idx].percentage = newLeft; + allocs[idx + 1].percentage = newRight; + + // Normalize to exactly 100 + const total = allocs.reduce((s, a) => s + a.percentage, 0); + if (total !== 100 && allocs.length > 0) { + allocs[allocs.length - 1].percentage += 100 - total; + allocs[allocs.length - 1].percentage = Math.max(MIN_PCT, allocs[allocs.length - 1].percentage); + } + + // 60fps visual update: patch split control SVG in-place + this.updateSplitControlVisual(this._splitDragNodeId, this._splitDragAllocType!); + this.redrawEdges(); + } + + private handleSplitDragEnd() { + if (!this._splitDragging) return; + const nodeId = this._splitDragNodeId; + this._splitDragging = false; + this._splitDragNodeId = null; + this._splitDragAllocType = null; + this._splitDragStartPcts = []; + if (nodeId) { + this.refreshEditorIfOpen(nodeId); + this.scheduleSave(); + } + } + + /** Patch split control segment widths and divider positions without full re-render */ + private updateSplitControlVisual(nodeId: string, allocType: string) { + const nodeLayer = this.shadow.getElementById("node-layer"); + if (!nodeLayer) return; + const ctrl = nodeLayer.querySelector(`.split-control[data-node-id="${nodeId}"][data-alloc-type="${allocType}"]`) as SVGGElement | null; + if (!ctrl) return; + const allocs = this.getSplitAllocs(nodeId, allocType); + if (!allocs) return; + + const track = ctrl.querySelector(".split-track") as SVGRectElement | null; + if (!track) return; + const trackX = parseFloat(track.getAttribute("x")!); + const trackW = parseFloat(track.getAttribute("width")!); + + // Update segment rects + const segs = ctrl.querySelectorAll(".split-seg"); + let segX = trackX; + segs.forEach((seg, i) => { + if (i >= allocs.length) return; + const segW = trackW * (allocs[i].percentage / 100); + (seg as SVGRectElement).setAttribute("x", String(segX)); + (seg as SVGRectElement).setAttribute("width", String(Math.max(segW, 2))); + segX += segW; + }); + + // Update divider positions and labels + const dividers = ctrl.querySelectorAll(".split-divider"); + let divX = trackX; + dividers.forEach((div, i) => { + if (i >= allocs.length - 1) return; + divX += trackW * (allocs[i].percentage / 100); + const rect = div.querySelector("rect"); + const text = div.querySelector("text"); + if (rect) rect.setAttribute("x", String(divX - 6)); + if (text) { + text.setAttribute("x", String(divX)); + text.textContent = `${Math.round(allocs[i].percentage)}% | ${Math.round(allocs[i + 1].percentage)}%`; + } + }); + } + private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) { const node = this.nodes.find((n) => n.id === fromId); if (!node) return;