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 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; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue