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:
parent
9c6c8c8dab
commit
50054d599e
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue