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:
parent
4cdba2e7de
commit
e6f78a67e8
|
|
@ -525,6 +525,13 @@
|
||||||
.satisfaction-bar-bg { opacity: 0.3; }
|
.satisfaction-bar-bg { opacity: 0.3; }
|
||||||
.satisfaction-bar-fill { transition: width 0.3s ease; }
|
.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 ──────────────────────────────── */
|
/* ── Node detail modals ──────────────────────────────── */
|
||||||
.flows-modal-backdrop {
|
.flows-modal-backdrop {
|
||||||
position: fixed; inset: 0; z-index: 50;
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
|
@ -662,8 +669,8 @@
|
||||||
/* Inline edit overlay container */
|
/* Inline edit overlay container */
|
||||||
.inline-edit-overlay { pointer-events: all; }
|
.inline-edit-overlay { pointer-events: all; }
|
||||||
|
|
||||||
/* Funnel overflow pipe (vessel metaphor) */
|
/* Funnel overflow pipe (vessel metaphor) — fixed height, animate opacity + fill */
|
||||||
.funnel-pipe { transition: fill 0.3s, height 0.3s, y 0.3s; }
|
.funnel-pipe { transition: opacity 0.2s ease, fill 0.2s ease; }
|
||||||
.funnel-pipe--active { fill: #10b981; }
|
.funnel-pipe--active { fill: #10b981; }
|
||||||
|
|
||||||
/* Threshold lines inside tank */
|
/* Threshold lines inside tank */
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,17 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
private draggingEdgeKey: string | null = null;
|
private draggingEdgeKey: string | null = null;
|
||||||
private edgeDragPointerId: number | 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
|
// Source purchase modal state
|
||||||
private sourceModalNodeId: string | null = null;
|
private sourceModalNodeId: string | null = null;
|
||||||
|
|
||||||
|
|
@ -1308,6 +1319,11 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const DRAG_THRESHOLD = 5;
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
this._boundPointerMove = (e: PointerEvent) => {
|
this._boundPointerMove = (e: PointerEvent) => {
|
||||||
|
// Split control drag
|
||||||
|
if (this._splitDragging) {
|
||||||
|
this.handleSplitDragMove(e.clientX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.wiringActive && this.wiringDragging) {
|
if (this.wiringActive && this.wiringDragging) {
|
||||||
this.wiringPointerX = e.clientX;
|
this.wiringPointerX = e.clientX;
|
||||||
this.wiringPointerY = e.clientY;
|
this.wiringPointerY = e.clientY;
|
||||||
|
|
@ -1355,6 +1371,11 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
this._boundPointerUp = (e: PointerEvent) => {
|
this._boundPointerUp = (e: PointerEvent) => {
|
||||||
|
// Split control drag end
|
||||||
|
if (this._splitDragging) {
|
||||||
|
this.handleSplitDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.wiringActive && this.wiringDragging) {
|
if (this.wiringActive && this.wiringDragging) {
|
||||||
// Hit-test: did we release on a compatible input port?
|
// Hit-test: did we release on a compatible input port?
|
||||||
const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
|
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");
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
||||||
if (edgeLayer) {
|
if (edgeLayer) {
|
||||||
edgeLayer.addEventListener("click", (e: Event) => {
|
// Edge selection — click on edge path
|
||||||
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)
|
|
||||||
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
|
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
const target = e.target as Element;
|
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;
|
if (target.closest(".edge-drag-handle")) return;
|
||||||
|
|
||||||
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
|
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
|
||||||
|
|
@ -1601,7 +1635,6 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
// Double-click edge → open source node editor
|
// Double-click edge → open source node editor
|
||||||
edgeLayer.addEventListener("dblclick", (e: Event) => {
|
edgeLayer.addEventListener("dblclick", (e: Event) => {
|
||||||
const target = e.target as Element;
|
const target = e.target as Element;
|
||||||
if (target.closest("[data-edge-action]")) return;
|
|
||||||
if (target.closest(".edge-drag-handle")) return;
|
if (target.closest(".edge-drag-handle")) return;
|
||||||
|
|
||||||
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
|
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 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`;
|
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)
|
// Stream: rect from nozzle tip downward, width from Sankey pre-pass
|
||||||
const streamW = Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5));
|
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 streamX = nozzleEndX;
|
||||||
const streamY = nozzleEndY + nozzleBotW;
|
const streamY = nozzleEndY + nozzleBotW;
|
||||||
const streamH = h - streamY;
|
const streamH = h - streamY;
|
||||||
|
|
||||||
// Allocation bar
|
// Split control replaces old allocation bar
|
||||||
let allocBar = "";
|
const allocBar = d.targetAllocations && d.targetAllocations.length >= 2
|
||||||
if (d.targetAllocations && d.targetAllocations.length > 0) {
|
? this.renderSplitControl(n.id, "source", d.targetAllocations, w / 2, h - 8, w - 40)
|
||||||
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("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
|
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"/>
|
<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 minFrac = d.minThreshold / (d.maxCapacity || 1);
|
||||||
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
||||||
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
||||||
let pipeH = basePipeH;
|
// Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps
|
||||||
let pipeY = Math.round(maxLineY - basePipeH / 2);
|
const pipeH = basePipeH;
|
||||||
let excessRatio = 0;
|
const pipeY = Math.round(maxLineY - basePipeH / 2);
|
||||||
if (isOverflow && d.maxCapacity > d.maxThreshold) {
|
const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold
|
||||||
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
|
? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold))
|
||||||
pipeH = basePipeH + Math.round(excessRatio * 16);
|
: 0;
|
||||||
pipeY = Math.round(maxLineY - pipeH / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wall inset at pipe Y position for pipe attachment
|
// Wall inset at pipe Y position for pipe attachment
|
||||||
const pipeYFrac = (maxLineY - zoneTop) / zoneH;
|
const pipeYFrac = (maxLineY - zoneTop) / zoneH;
|
||||||
|
|
@ -1964,6 +1986,31 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
rightWall.push(`${w - inset},${py}`);
|
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 = [
|
const vesselPath = [
|
||||||
`M ${r},0`,
|
`M ${r},0`,
|
||||||
`L ${w - r},0`,
|
`L ${w - r},0`,
|
||||||
|
|
@ -1972,12 +2019,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
`L ${w},${pipeY}`,
|
`L ${w},${pipeY}`,
|
||||||
`L ${w + pipeW},${pipeY}`,
|
`L ${w + pipeW},${pipeY}`,
|
||||||
`L ${w + pipeW},${pipeY + pipeH}`,
|
`L ${w + pipeW},${pipeY + pipeH}`,
|
||||||
`L ${w},${pipeY + pipeH}`,
|
// Continue right wall tapering from interpolated pipe bottom point
|
||||||
// Continue right wall tapering down
|
...rightWallBelow.map(p => `L ${p}`),
|
||||||
...rightWall.filter((_, i) => {
|
|
||||||
const py = zoneTop + zoneH * (i / steps);
|
|
||||||
return py > pipeY + pipeH;
|
|
||||||
}).map(p => `L ${p}`),
|
|
||||||
// Bottom: narrow drain spout with rounded corners
|
// Bottom: narrow drain spout with rounded corners
|
||||||
`L ${w - taperAtBottom + r},${zoneBot}`,
|
`L ${w - taperAtBottom + r},${zoneBot}`,
|
||||||
`Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`,
|
`Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`,
|
||||||
|
|
@ -1985,13 +2028,9 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
`L ${taperAtBottom},${h}`,
|
`L ${taperAtBottom},${h}`,
|
||||||
`L ${taperAtBottom},${h - r}`,
|
`L ${taperAtBottom},${h - r}`,
|
||||||
`Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`,
|
`Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`,
|
||||||
// Left wall tapering up
|
// Left wall tapering up from interpolated pipe bottom point
|
||||||
...leftWall.filter((_, i) => {
|
...leftWallBelow.map(p => `L ${p}`),
|
||||||
const py = zoneTop + zoneH * (i / steps);
|
|
||||||
return py > pipeY + pipeH;
|
|
||||||
}).reverse().map(p => `L ${p}`),
|
|
||||||
// Left pipe notch
|
// Left pipe notch
|
||||||
`L 0,${pipeY + pipeH}`,
|
|
||||||
`L ${-pipeW},${pipeY + pipeH}`,
|
`L ${-pipeW},${pipeY + pipeH}`,
|
||||||
`L ${-pipeW},${pipeY}`,
|
`L ${-pipeW},${pipeY}`,
|
||||||
`L 0,${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-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)"/>` : "";
|
<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
|
// Inflow satisfaction bar
|
||||||
const satBarY = 50;
|
const satBarY = 50;
|
||||||
const satBarW = w - 48;
|
const satBarW = w - 48;
|
||||||
|
|
@ -2074,6 +2122,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
${shimmerLine}
|
${shimmerLine}
|
||||||
${thresholdLines}
|
${thresholdLines}
|
||||||
</g>
|
</g>
|
||||||
|
${inflowPipeIndicator}
|
||||||
<!-- Overflow pipes at max threshold -->
|
<!-- 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="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}"/>
|
<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 ▷
|
◁ ${this.formatDollar(outflow)}/mo ▷
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</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}">
|
<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"
|
<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"/>
|
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>
|
<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>
|
</g>
|
||||||
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
|
<!-- Inflow label -->
|
||||||
<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">
|
<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>
|
||||||
<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>
|
<!-- Node label + status badge -->
|
||||||
<div style="position:absolute;top:38px;left:${pipeW + 10}px;right:${pipeW + 10}px;display:flex;align-items:center;justify-content:space-between">
|
<foreignObject x="0" y="0" width="${w}" height="32" class="funnel-overlay">
|
||||||
<span style="font-size:14px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
|
<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:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
|
<span style="font-size:14px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
|
||||||
</div>
|
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
|
||||||
<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>
|
</div>
|
||||||
</foreignObject>
|
</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)}
|
${this.renderPortsSvg(n)}
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2209,6 +2271,45 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
return bar;
|
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 ───────────────────────────────────
|
// ─── Edge rendering ───────────────────────────────────
|
||||||
|
|
||||||
private formatDollar(amount: number): string {
|
private formatDollar(amount: number): string {
|
||||||
|
|
@ -2217,6 +2318,74 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
return `$${Math.round(amount)}`;
|
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 {
|
private renderAllEdges(): string {
|
||||||
// First pass: compute actual dollar flow per edge
|
// First pass: compute actual dollar flow per edge
|
||||||
interface EdgeInfo {
|
interface EdgeInfo {
|
||||||
|
|
@ -2308,16 +2477,26 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: render edges with flow-value-relative widths (Sankey-style)
|
// Pre-compute Sankey-consistent flow widths
|
||||||
const MAX_EDGE_W = 28;
|
this.computeFlowWidths();
|
||||||
|
|
||||||
|
// Second pass: render edges with per-node proportional widths (Sankey-consistent)
|
||||||
const MIN_EDGE_W = 3;
|
const MIN_EDGE_W = 3;
|
||||||
const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1);
|
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const e of edges) {
|
for (const e of edges) {
|
||||||
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
|
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
|
||||||
const to = this.getPortPosition(e.toNode, "inflow");
|
const to = this.getPortPosition(e.toNode, "inflow");
|
||||||
const isGhost = e.flowAmount === 0;
|
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}%)`;
|
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
|
||||||
html += this.renderEdgePath(
|
html += this.renderEdgePath(
|
||||||
from.x, from.y, to.x, to.y,
|
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"/>
|
<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})">
|
<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"/>
|
<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>
|
<text x="0" 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">−</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>
|
|
||||||
</g>
|
</g>
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2397,8 +2568,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const overflowMul = dashed ? 1.3 : 1;
|
const overflowMul = dashed ? 1.3 : 1;
|
||||||
const finalStrokeW = strokeW * overflowMul;
|
const finalStrokeW = strokeW * overflowMul;
|
||||||
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
||||||
// Wider label box to fit dollar amounts
|
// Label box — read-only, no +/- buttons (splits controlled at nodes)
|
||||||
const labelW = Math.max(68, label.length * 7 + 36);
|
const labelW = Math.max(68, label.length * 7 + 12);
|
||||||
const halfW = labelW / 2;
|
const halfW = labelW / 2;
|
||||||
// Drag handle at midpoint
|
// Drag handle at midpoint
|
||||||
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
|
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})">
|
<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"/>
|
<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>
|
<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">−</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>
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
@ -2853,6 +3016,111 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
// ─── Allocation adjustment ────────────────────────────
|
// ─── 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) {
|
private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) {
|
||||||
const node = this.nodes.find((n) => n.id === fromId);
|
const node = this.nodes.find((n) => n.id === fromId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue