Merge branch 'dev'
This commit is contained in:
commit
65009e3a5a
|
|
@ -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