feat(rflows): Sankey-consistent edge widths, split controls, vessel path fixes

- Add computeFlowWidths() pre-pass for per-node proportional edge widths
  (outgoing edges sum to node pipe width, 8-80px range)
- Replace +/- buttons on edges with draggable split controls on nodes
  (source, funnel spending, funnel overflow — min 5% clamp, 60fps updates)
- Fix vessel wall path discontinuities by interpolating at pipe boundaries
- Stabilize overflow pipe sizing (fixed height, CSS opacity transitions)
- Tighten funnel foreignObject bounds to eliminate pointer-events overlap
- Replace foreignObject zone/overflow labels with SVG <text> elements
- Add inflow pipe indicator bars on funnels showing flow fill ratio

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 15:44:14 -07:00
parent 4cdba2e7de
commit e6f78a67e8
2 changed files with 367 additions and 92 deletions

View File

@ -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 */

View File

@ -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<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number }> = 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 = `<rect x="${cx}" y="${barY}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.85"/>`;
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 `<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"/>
@ -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 {
<ellipse class="overflow-spill-left" cx="${-pipeW - 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>
<ellipse class="overflow-spill-right" cx="${w + pipeW + 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>` : "";
// 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 ? `
<rect x="${inflowPipeX}" y="26" width="${inflowPipeW}" height="6" rx="3" fill="var(--rs-bg-surface-raised)" opacity="0.3"/>
<rect x="${inflowPipeX}" y="26" width="${Math.round(inflowPipeW * inflowFillRatio)}" height="6" rx="3" fill="var(--rflows-label-inflow)" opacity="0.7"/>` : "";
// Inflow satisfaction bar
const satBarY = 50;
const satBarW = w - 48;
@ -2074,6 +2122,7 @@ class FolkFlowsApp extends HTMLElement {
${shimmerLine}
${thresholdLines}
</g>
${inflowPipeIndicator}
<!-- Overflow pipes at max threshold -->
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
@ -2088,28 +2137,41 @@ class FolkFlowsApp extends HTMLElement {
${this.formatDollar(outflow)}/mo
</text>
</g>
<!-- 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)
: ""}
<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"/>
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none"> capacity</text>
</g>
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
<div style="position:absolute;top:38px;left:${pipeW + 10}px;right:${pipeW + 10}px;display:flex;align-items:center;justify-content:space-between">
<!-- Inflow label -->
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#10b981" font-size="12" font-weight="500" opacity="0.9" pointer-events="none">\u2193 ${inflowLabel}</text>
<!-- Node label + status badge -->
<foreignObject x="0" y="0" width="${w}" height="32" class="funnel-overlay">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px 0;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<span style="font-size:14px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
</div>
<div style="position:absolute;top:${satBarY + 18 + 28}px;left:${pipeW + 10}px;right:${pipeW + 10}px;text-align:center;font-size:10px;color:var(--rs-text-secondary)">${satLabel}</div>
${criticalH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH + criticalH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#ef4444;opacity:0.5">CRITICAL</div>` : ""}
${sufficientH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">SUFFICIENT</div>` : ""}
${overflowH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">OVERFLOW</div>` : ""}
<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${drainInset + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:var(--rs-text-muted)">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
<div style="position:absolute;bottom:10px;width:100%;text-align:center;font-size:12px;font-weight:600;color:#34d399">${this.formatDollar(outflow)}/mo \u25BE</div>
${isOverflow ? `<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;left:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>
<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;right:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>` : ""}
</div>
</foreignObject>
<!-- Satisfaction label -->
<text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10" pointer-events="none">${satLabel}</text>
<!-- Zone labels (SVG text in clip group) -->
${criticalH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH + criticalH / 2 + 4}" text-anchor="middle" fill="#ef4444" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">CRITICAL</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">SUFFICIENT</text>` : ""}
${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.maxThreshold).toLocaleString()}</text>
<!-- Outflow label -->
<text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(outflow)}/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>` : ""}
${this.renderPortsSvg(n)}
</g>`;
}
@ -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 = `<g class="split-control" data-node-id="${nodeId}" data-alloc-type="${allocType}">`;
svg += `<rect class="split-track" x="${trackX}" y="${trackY}" width="${trackW}" height="${trackH}" rx="4" fill="var(--rs-bg-surface-raised)" opacity="0.5"/>`;
// Segments
let segX = trackX;
for (let i = 0; i < allocs.length; i++) {
const a = allocs[i];
const segW = trackW * (a.percentage / 100);
svg += `<rect class="split-seg" x="${segX}" y="${trackY}" width="${Math.max(segW, 2)}" height="${trackH}" ${i === 0 ? 'rx="4"' : ""} ${i === allocs.length - 1 ? 'rx="4"' : ""} fill="${a.color}" opacity="0.75"/>`;
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 += `<g class="split-divider" data-divider-idx="${i}" data-node-id="${nodeId}" data-alloc-type="${allocType}" style="cursor:ew-resize">
<rect x="${divX - 6}" y="${trackY - 3}" width="12" height="${trackH + 6}" rx="3" fill="var(--rs-bg-surface)" stroke="var(--rs-text-muted)" stroke-width="1" opacity="0.9"/>
<text x="${divX}" y="${trackY - 6}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="9" font-weight="600" pointer-events="none">${leftPct}% | ${rightPct}%</text>
</g>`;
}
svg += `</g>`;
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<string, { totalOutflow: number; totalInflow: number }>();
// 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 {
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/>
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="-34" y="-12" width="68" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.5"/>
<text x="-14" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="-32" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="-24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
</g>
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="16" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
<text x="0" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
</g>
</g>`;
}
@ -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 = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
@ -2413,14 +2584,6 @@ class FolkFlowsApp extends HTMLElement {
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
<text x="0" y="5" style="fill:${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="${-halfW + 2}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${-halfW + 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
</g>
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="${halfW - 18}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${halfW - 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
</g>
</g>`;
}
@ -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;