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 .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) */
.edge-ghost { pointer-events: none; }

View File

@ -11,7 +11,7 @@
* 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 { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
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 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
private inlineEditNodeId: string | null = null;
private inlineEditDragThreshold: string | null = null;
@ -698,9 +703,10 @@ class FolkFlowsApp extends HTMLElement {
svg.classList.add("panning");
svg.setPointerCapture(e.pointerId);
// Deselect node
if (!target.closest(".flow-node")) {
// Deselect node and edge
if (!target.closest(".flow-node") && !target.closest(".edge-group")) {
this.selectedNodeId = null;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
}
});
@ -716,6 +722,15 @@ class FolkFlowsApp extends HTMLElement {
this.updateWiringTempLine();
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) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
@ -757,6 +772,11 @@ class FolkFlowsApp extends HTMLElement {
}
return;
}
// Edge drag end
if (this.draggingEdgeKey) {
this.draggingEdgeKey = null;
this.edgeDragPointerId = null;
}
if (this.isPanning) {
this.isPanning = false;
svg.classList.remove("panning");
@ -771,6 +791,7 @@ class FolkFlowsApp extends HTMLElement {
// Single click = select only (inline edit on double-click)
if (!wasDragged) {
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
}
}
@ -829,6 +850,7 @@ class FolkFlowsApp extends HTMLElement {
// Select
this.selectedNodeId = nodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
// 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";
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
@ -1106,7 +1196,7 @@ class FolkFlowsApp extends HTMLElement {
`Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner
`L ${insetPx + r},${h}`, // across narrow bottom
`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 ${-lipW},${lipH + lipNotch}`, // left lip bottom
`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))"
: !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}">
<defs>
<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}" 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="${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>
<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}"/>
<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 - 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="${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="${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) * 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)}
</g>`;
}
@ -1240,6 +1345,7 @@ class FolkFlowsApp extends HTMLElement {
fromId: string;
toId: string;
edgeType: string;
waypoint?: { x: number; y: number };
}
const edges: EdgeInfo[] = [];
@ -1255,6 +1361,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#10b981", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "source",
waypoint: alloc.waypoint,
});
}
}
@ -1273,6 +1380,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
});
}
// Spending edges — rate-based drain
@ -1290,6 +1398,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#34d399", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "spending",
waypoint: alloc.waypoint,
});
}
}
@ -1307,27 +1416,27 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
});
}
}
}
// Find max flow amount for width normalization
const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount));
// Second pass: render edges with normalized widths
// Second pass: render edges with percentage-proportional widths
const MAX_EDGE_W = 16;
const MIN_EDGE_W = 1.5;
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 : 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}%)`;
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
e.color, strokeW, e.dashed, isGhost,
label, e.fromId, e.toId, e.edgeType,
e.fromSide,
e.fromSide, e.waypoint,
);
}
return html;
@ -1338,12 +1447,30 @@ class FolkFlowsApp extends HTMLElement {
color: string, strokeW: number, dashed: boolean, ghost: boolean,
label: string, fromId: string, toId: string, edgeType: string,
fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): string {
let d: string;
let midX: 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
const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60;
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;
}
// 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) {
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"/>
<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"/>
@ -1379,9 +1510,13 @@ class FolkFlowsApp extends HTMLElement {
// Wider label box to fit dollar amounts
const labelW = Math.max(68, label.length * 7 + 36);
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}" stroke-opacity="0.8" class="${animClass}"/>
${dragHandle}
<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" 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();
}
// ─── 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 ──────────────────────────────
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 {

View File

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