feat: clickable/draggable edges with proportional width sizing in rFlows

Edges are now interactive: click to select (purple highlight), double-click
to open source node editor, drag midpoint handle to add curve waypoints.
Edge stroke widths are directly proportional to allocation percentage
instead of normalized to the largest flow amount.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-04 13:00:58 -08:00
parent 9c6c8c8dab
commit 50054d599e
3 changed files with 231 additions and 15 deletions

View File

@ -380,6 +380,40 @@
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); } .edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; } .edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
/* Selected edge */
.edge-group--selected path:not(.edge-glow):not(.edge-hit-area) {
stroke: #6366f1 !important;
stroke-opacity: 1 !important;
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.5));
}
.edge-group--selected .edge-glow {
stroke: #6366f1 !important;
stroke-opacity: 0.25 !important;
}
.edge-group--selected .edge-ctrl-group rect:first-child {
stroke: #6366f1;
}
/* Drag handle on edge midpoint */
.edge-drag-handle {
fill: #475569;
stroke: #94a3b8;
stroke-width: 1.5;
cursor: grab;
transition: fill 0.15s;
}
.edge-drag-handle:hover {
fill: #6366f1;
stroke: #818cf8;
}
.edge-group--selected .edge-drag-handle {
fill: #6366f1;
stroke: #a5b4fc;
}
/* Invisible hit area for edge click targeting */
.edge-hit-area { pointer-events: stroke; cursor: pointer; }
/* Ghost edge (zero-flow potential paths) */ /* Ghost edge (zero-flow potential paths) */
.edge-ghost { pointer-events: none; } .edge-ghost { pointer-events: none; }

View File

@ -11,7 +11,7 @@
* mode "demo" to use hardcoded demo data (no API) * mode "demo" to use hardcoded demo data (no API)
*/ */
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } from "../lib/types"; import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS } from "../lib/types"; import { PORT_DEFS } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets"; import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
@ -93,6 +93,11 @@ class FolkFlowsApp extends HTMLElement {
private simInterval: ReturnType<typeof setInterval> | null = null; private simInterval: ReturnType<typeof setInterval> | null = null;
private canvasInitialized = false; private canvasInitialized = false;
// Edge selection & drag state
private selectedEdgeKey: string | null = null; // "fromId::toId::edgeType"
private draggingEdgeKey: string | null = null;
private edgeDragPointerId: number | null = null;
// Inline edit state // Inline edit state
private inlineEditNodeId: string | null = null; private inlineEditNodeId: string | null = null;
private inlineEditDragThreshold: string | null = null; private inlineEditDragThreshold: string | null = null;
@ -698,9 +703,10 @@ class FolkFlowsApp extends HTMLElement {
svg.classList.add("panning"); svg.classList.add("panning");
svg.setPointerCapture(e.pointerId); svg.setPointerCapture(e.pointerId);
// Deselect node // Deselect node and edge
if (!target.closest(".flow-node")) { if (!target.closest(".flow-node") && !target.closest(".edge-group")) {
this.selectedNodeId = null; this.selectedNodeId = null;
this.selectedEdgeKey = null;
this.updateSelectionHighlight(); this.updateSelectionHighlight();
} }
}); });
@ -716,6 +722,15 @@ class FolkFlowsApp extends HTMLElement {
this.updateWiringTempLine(); this.updateWiringTempLine();
return; return;
} }
// Edge drag — convert pointer to canvas coords and update waypoint
if (this.draggingEdgeKey) {
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
this.redrawEdges();
return;
}
if (this.isPanning) { if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
@ -757,6 +772,11 @@ class FolkFlowsApp extends HTMLElement {
} }
return; return;
} }
// Edge drag end
if (this.draggingEdgeKey) {
this.draggingEdgeKey = null;
this.edgeDragPointerId = null;
}
if (this.isPanning) { if (this.isPanning) {
this.isPanning = false; this.isPanning = false;
svg.classList.remove("panning"); svg.classList.remove("panning");
@ -771,6 +791,7 @@ class FolkFlowsApp extends HTMLElement {
// Single click = select only (inline edit on double-click) // Single click = select only (inline edit on double-click)
if (!wasDragged) { if (!wasDragged) {
this.selectedNodeId = clickedNodeId; this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight(); this.updateSelectionHighlight();
} }
} }
@ -829,6 +850,7 @@ class FolkFlowsApp extends HTMLElement {
// Select // Select
this.selectedNodeId = nodeId; this.selectedNodeId = nodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight(); this.updateSelectionHighlight();
// Prepare drag (but don't start until threshold exceeded) // Prepare drag (but don't start until threshold exceeded)
@ -902,6 +924,74 @@ class FolkFlowsApp extends HTMLElement {
const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source"; const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5); this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
}); });
// Edge selection — click on edge path (not buttons)
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;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
const key = `${fromId}::${toId}::${edgeType}`;
this.selectedEdgeKey = key;
this.selectedNodeId = null;
this.updateSelectionHighlight();
});
// 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;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
this.openEditor(fromId);
});
// Edge drag handle — pointerdown to start dragging
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
e.preventDefault();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.draggingEdgeKey = `${fromId}::${toId}::${edgeType}`;
this.edgeDragPointerId = e.pointerId;
(e.target as Element).setPointerCapture?.(e.pointerId);
});
// Double-click drag handle → remove waypoint
edgeLayer.addEventListener("dblclick", (e: Event) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.removeEdgeWaypoint(fromId, toId, edgeType);
});
} }
// Touch gesture handling for two-finger pan + pinch-to-zoom // Touch gesture handling for two-finger pan + pinch-to-zoom
@ -1106,7 +1196,7 @@ class FolkFlowsApp extends HTMLElement {
`Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner `Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner
`L ${insetPx + r},${h}`, // across narrow bottom `L ${insetPx + r},${h}`, // across narrow bottom
`Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner `Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner
`Q ${insetPx},${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left `Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left
`L 0,${lipH + lipNotch}`, // up left side from taper `L 0,${lipH + lipNotch}`, // up left side from taper
`L ${-lipW},${lipH + lipNotch}`, // left lip bottom `L ${-lipW},${lipH + lipNotch}`, // left lip bottom
`L ${-lipW},${lipH}`, // left lip top `L ${-lipW},${lipH}`, // left lip top
@ -1143,6 +1233,17 @@ class FolkFlowsApp extends HTMLElement {
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))" const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : ""; : !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
// Rate labels
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
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 spendingRate = d.inflowRate * rateMultiplier;
const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}"> return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs> <defs>
<clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath> <clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath>
@ -1153,18 +1254,22 @@ class FolkFlowsApp extends HTMLElement {
<rect x="${-lipW}" y="${zoneTop + overflowH + healthyH}" width="${w + lipW * 2}" height="${drainH}" fill="#ef4444" opacity="0.08"/> <rect x="${-lipW}" y="${zoneTop + overflowH + healthyH}" width="${w + lipW * 2}" height="${drainH}" fill="#ef4444" opacity="0.08"/>
<rect x="${-lipW}" y="${zoneTop + overflowH}" width="${w + lipW * 2}" height="${healthyH}" fill="#0ea5e9" opacity="0.06"/> <rect x="${-lipW}" y="${zoneTop + overflowH}" width="${w + lipW * 2}" height="${healthyH}" fill="#0ea5e9" opacity="0.06"/>
<rect x="${-lipW}" y="${zoneTop}" width="${w + lipW * 2}" height="${overflowH}" fill="#f59e0b" opacity="0.06"/> <rect x="${-lipW}" y="${zoneTop}" width="${w + lipW * 2}" height="${overflowH}" fill="#f59e0b" opacity="0.06"/>
<rect x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/> <rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
</g> </g>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/> <rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/> <rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#10b981" font-size="10" font-weight="500" opacity="0.8">${inflowLabel}</text>
<text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</text> <text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text> <text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/> <rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/> <rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${satLabel}</text> <text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${satLabel}</text>
<text x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text> <text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/> <rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/>
<rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" fill="${fillColor}"/> <rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
<text x="${w / 2}" y="${h + 16}" text-anchor="middle" fill="#34d399" font-size="10" font-weight="500" opacity="0.8">${spendingLabel}</text>
${isOverflow ? `<text x="${-lipW - 4}" y="${lipH + lipNotch / 2 + 3}" text-anchor="end" fill="#6ee7b7" font-size="9" opacity="0.7">${overflowLabel}</text>
<text x="${w + lipW + 4}" y="${lipH + lipNotch / 2 + 3}" text-anchor="start" fill="#6ee7b7" font-size="9" opacity="0.7">${overflowLabel}</text>` : ""}
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
@ -1240,6 +1345,7 @@ class FolkFlowsApp extends HTMLElement {
fromId: string; fromId: string;
toId: string; toId: string;
edgeType: string; edgeType: string;
waypoint?: { x: number; y: number };
} }
const edges: EdgeInfo[] = []; const edges: EdgeInfo[] = [];
@ -1255,6 +1361,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#10b981", flowAmount, color: "#10b981", flowAmount,
pct: alloc.percentage, dashed: false, pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "source", fromId: n.id, toId: alloc.targetId, edgeType: "source",
waypoint: alloc.waypoint,
}); });
} }
} }
@ -1273,6 +1380,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount, color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true, pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow", fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
}); });
} }
// Spending edges — rate-based drain // Spending edges — rate-based drain
@ -1290,6 +1398,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#34d399", flowAmount, color: "#34d399", flowAmount,
pct: alloc.percentage, dashed: false, pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "spending", fromId: n.id, toId: alloc.targetId, edgeType: "spending",
waypoint: alloc.waypoint,
}); });
} }
} }
@ -1307,27 +1416,27 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount, color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true, pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow", fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
}); });
} }
} }
} }
// Find max flow amount for width normalization // Second pass: render edges with percentage-proportional widths
const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount)); const MAX_EDGE_W = 16;
const MIN_EDGE_W = 1.5;
// Second pass: render edges with normalized widths
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 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14); const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - 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,
e.color, strokeW, e.dashed, isGhost, e.color, strokeW, e.dashed, isGhost,
label, e.fromId, e.toId, e.edgeType, label, e.fromId, e.toId, e.edgeType,
e.fromSide, e.fromSide, e.waypoint,
); );
} }
return html; return html;
@ -1338,12 +1447,30 @@ class FolkFlowsApp extends HTMLElement {
color: string, strokeW: number, dashed: boolean, ghost: boolean, color: string, strokeW: number, dashed: boolean, ghost: boolean,
label: string, fromId: string, toId: string, edgeType: string, label: string, fromId: string, toId: string, edgeType: string,
fromSide?: "left" | "right", fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): string { ): string {
let d: string; let d: string;
let midX: number; let midX: number;
let midY: number; let midY: number;
if (fromSide) { if (waypoint) {
// Cubic Bezier that passes through waypoint at t=0.5:
// P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3
// To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start,
// C2 = (4W - P0 - P3) / 3 blended toward end
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
const cx2 = cx1;
const cy2 = cy1;
// Blend control points to retain start/end tangent direction
const c1x = x1 + (cx1 - x1) * 0.8;
const c1y = y1 + (cy1 - y1) * 0.8;
const c2x = x2 + (cx2 - x2) * 0.8;
const c2y = y2 + (cy2 - y2) * 0.8;
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
midX = waypoint.x;
midY = waypoint.y;
} else if (fromSide) {
// Side port: curve outward horizontally first, then turn toward target // Side port: curve outward horizontally first, then turn toward target
const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60; const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
@ -1357,8 +1484,12 @@ class FolkFlowsApp extends HTMLElement {
midY = (y1 + y2) / 2; midY = (y1 + y2) / 2;
} }
// Invisible wide hit area for click/selection
const hitPath = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;
if (ghost) { if (ghost) {
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}"> return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/> <path d="${d}" fill="none" 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"/>
@ -1379,9 +1510,13 @@ class FolkFlowsApp extends HTMLElement {
// Wider label box to fit dollar amounts // Wider label box to fit dollar amounts
const labelW = Math.max(68, label.length * 7 + 36); const labelW = Math.max(68, label.length * 7 + 36);
const halfW = labelW / 2; const halfW = labelW / 2;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}"> // Drag handle at midpoint
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/> <path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/> <path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
${dragHandle}
<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" fill="${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text> <text x="0" y="5" fill="${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
@ -1402,6 +1537,38 @@ class FolkFlowsApp extends HTMLElement {
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges(); if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
} }
// ─── Edge waypoint helpers ──────────────────────────────
private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null {
const node = this.nodes.find((n) => n.id === fromId);
if (!node) return null;
if (edgeType === "source" && node.type === "source") {
return (node.data as SourceNodeData).targetAllocations.find((a) => a.targetId === toId) || null;
}
if (edgeType === "overflow") {
if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations.find((a) => a.targetId === toId) || null;
if (node.type === "outcome") return ((node.data as OutcomeNodeData).overflowAllocations || []).find((a) => a.targetId === toId) || null;
}
if (edgeType === "spending" && node.type === "funnel") {
return (node.data as FunnelNodeData).spendingAllocations.find((a) => a.targetId === toId) || null;
}
return null;
}
private setEdgeWaypoint(edgeKey: string, x: number, y: number) {
const [fromId, toId, edgeType] = edgeKey.split("::");
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
if (alloc) alloc.waypoint = { x, y };
}
private removeEdgeWaypoint(fromId: string, toId: string, edgeType: string) {
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
if (alloc) {
delete alloc.waypoint;
this.redrawEdges();
}
}
// ─── Selection highlight ────────────────────────────── // ─── Selection highlight ──────────────────────────────
private updateSelectionHighlight() { private updateSelectionHighlight() {
@ -1427,6 +1594,18 @@ class FolkFlowsApp extends HTMLElement {
} }
} }
}); });
// Edge selection highlight
const edgeLayer = this.shadow.getElementById("edge-layer");
if (!edgeLayer) return;
edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
const el = g as SVGGElement;
const fromId = el.dataset.from;
const toId = el.dataset.to;
const edgeType = el.dataset.edgeType || "source";
const key = `${fromId}::${toId}::${edgeType}`;
el.classList.toggle("edge-group--selected", key === this.selectedEdgeKey);
});
} }
private getNodeBorderColor(n: FlowNode): string { private getNodeBorderColor(n: FlowNode): string {

View File

@ -20,18 +20,21 @@ export interface OverflowAllocation {
targetId: string; targetId: string;
percentage: number; percentage: number;
color: string; color: string;
waypoint?: { x: number; y: number };
} }
export interface SpendingAllocation { export interface SpendingAllocation {
targetId: string; targetId: string;
percentage: number; percentage: number;
color: string; color: string;
waypoint?: { x: number; y: number };
} }
export interface SourceAllocation { export interface SourceAllocation {
targetId: string; targetId: string;
percentage: number; percentage: number;
color: string; color: string;
waypoint?: { x: number; y: number };
} }
export type SufficiencyState = "seeking" | "sufficient" | "abundant"; export type SufficiencyState = "seeking" | "sufficient" | "abundant";