diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index 51e1d94..330f866 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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; } diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index 77e19d0..8c92ae8 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -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 | 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 ` @@ -1153,18 +1254,22 @@ class FolkFlowsApp extends HTMLElement { - + + ${inflowLabel} ${this.esc(d.label)} ${statusLabel} ${satLabel} - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} + $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} + ${spendingLabel} + ${isOverflow ? `${overflowLabel} + ${overflowLabel}` : ""} ${this.renderPortsSvg(n)} `; } @@ -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 = ``; + if (ghost) { - return ` + return ` + ${hitPath} @@ -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 ` + // Drag handle at midpoint + const dragHandle = ``; + return ` + ${hitPath} + ${dragHandle} ${label} @@ -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 { diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index cd61de7..013dff5 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -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";