Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-04 13:01:07 -08:00
commit 65009e3a5a
3 changed files with 231 additions and 15 deletions

View File

@ -380,6 +380,40 @@
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
/* Selected edge */
.edge-group--selected path:not(.edge-glow):not(.edge-hit-area) {
stroke: #6366f1 !important;
stroke-opacity: 1 !important;
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.5));
}
.edge-group--selected .edge-glow {
stroke: #6366f1 !important;
stroke-opacity: 0.25 !important;
}
.edge-group--selected .edge-ctrl-group rect:first-child {
stroke: #6366f1;
}
/* Drag handle on edge midpoint */
.edge-drag-handle {
fill: #475569;
stroke: #94a3b8;
stroke-width: 1.5;
cursor: grab;
transition: fill 0.15s;
}
.edge-drag-handle:hover {
fill: #6366f1;
stroke: #818cf8;
}
.edge-group--selected .edge-drag-handle {
fill: #6366f1;
stroke: #a5b4fc;
}
/* Invisible hit area for edge click targeting */
.edge-hit-area { pointer-events: stroke; cursor: pointer; }
/* Ghost edge (zero-flow potential paths) */
.edge-ghost { pointer-events: none; }

View File

@ -11,7 +11,7 @@
* mode "demo" to use hardcoded demo data (no API)
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } from "../lib/types";
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
import { PORT_DEFS } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
@ -93,6 +93,11 @@ class FolkFlowsApp extends HTMLElement {
private simInterval: ReturnType<typeof setInterval> | null = null;
private canvasInitialized = false;
// Edge selection & drag state
private selectedEdgeKey: string | null = null; // "fromId::toId::edgeType"
private draggingEdgeKey: string | null = null;
private edgeDragPointerId: number | null = null;
// Inline edit state
private inlineEditNodeId: string | null = null;
private inlineEditDragThreshold: string | null = null;
@ -698,9 +703,10 @@ class FolkFlowsApp extends HTMLElement {
svg.classList.add("panning");
svg.setPointerCapture(e.pointerId);
// Deselect node
if (!target.closest(".flow-node")) {
// Deselect node and edge
if (!target.closest(".flow-node") && !target.closest(".edge-group")) {
this.selectedNodeId = null;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
}
});
@ -716,6 +722,15 @@ class FolkFlowsApp extends HTMLElement {
this.updateWiringTempLine();
return;
}
// Edge drag — convert pointer to canvas coords and update waypoint
if (this.draggingEdgeKey) {
const rect = svg.getBoundingClientRect();
const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
this.redrawEdges();
return;
}
if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
@ -757,6 +772,11 @@ class FolkFlowsApp extends HTMLElement {
}
return;
}
// Edge drag end
if (this.draggingEdgeKey) {
this.draggingEdgeKey = null;
this.edgeDragPointerId = null;
}
if (this.isPanning) {
this.isPanning = false;
svg.classList.remove("panning");
@ -771,6 +791,7 @@ class FolkFlowsApp extends HTMLElement {
// Single click = select only (inline edit on double-click)
if (!wasDragged) {
this.selectedNodeId = clickedNodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
}
}
@ -829,6 +850,7 @@ class FolkFlowsApp extends HTMLElement {
// Select
this.selectedNodeId = nodeId;
this.selectedEdgeKey = null;
this.updateSelectionHighlight();
// Prepare drag (but don't start until threshold exceeded)
@ -902,6 +924,74 @@ class FolkFlowsApp extends HTMLElement {
const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
});
// Edge selection — click on edge path (not buttons)
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element;
// Ignore clicks on +/- buttons or drag handle
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
const key = `${fromId}::${toId}::${edgeType}`;
this.selectedEdgeKey = key;
this.selectedNodeId = null;
this.updateSelectionHighlight();
});
// Double-click edge → open source node editor
edgeLayer.addEventListener("dblclick", (e: Event) => {
const target = e.target as Element;
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
e.stopPropagation();
const fromId = edgeGroup.dataset.from!;
this.openEditor(fromId);
});
// Edge drag handle — pointerdown to start dragging
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
e.preventDefault();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.draggingEdgeKey = `${fromId}::${toId}::${edgeType}`;
this.edgeDragPointerId = e.pointerId;
(e.target as Element).setPointerCapture?.(e.pointerId);
});
// Double-click drag handle → remove waypoint
edgeLayer.addEventListener("dblclick", (e: Event) => {
const handle = (e.target as Element).closest(".edge-drag-handle") as SVGElement | null;
if (!handle) return;
e.stopPropagation();
const edgeGroup = handle.closest(".edge-group") as SVGGElement | null;
if (!edgeGroup) return;
const fromId = edgeGroup.dataset.from!;
const toId = edgeGroup.dataset.to!;
const edgeType = edgeGroup.dataset.edgeType || "source";
this.removeEdgeWaypoint(fromId, toId, edgeType);
});
}
// Touch gesture handling for two-finger pan + pinch-to-zoom
@ -1106,7 +1196,7 @@ class FolkFlowsApp extends HTMLElement {
`Q ${w - insetPx},${h} ${w - insetPx - r},${h}`, // bottom-right corner
`L ${insetPx + r},${h}`, // across narrow bottom
`Q ${insetPx},${h} ${insetPx},${h - r}`, // bottom-left corner
`Q ${insetPx},${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left
`Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`, // taper curve left
`L 0,${lipH + lipNotch}`, // up left side from taper
`L ${-lipW},${lipH + lipNotch}`, // left lip bottom
`L ${-lipW},${lipH}`, // left lip top
@ -1143,6 +1233,17 @@ class FolkFlowsApp extends HTMLElement {
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
// Rate labels
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
let rateMultiplier: number;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const spendingRate = d.inflowRate * rateMultiplier;
const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath>
@ -1153,18 +1254,22 @@ class FolkFlowsApp extends HTMLElement {
<rect x="${-lipW}" y="${zoneTop + overflowH + healthyH}" width="${w + lipW * 2}" height="${drainH}" fill="#ef4444" opacity="0.08"/>
<rect x="${-lipW}" y="${zoneTop + overflowH}" width="${w + lipW * 2}" height="${healthyH}" fill="#0ea5e9" opacity="0.06"/>
<rect x="${-lipW}" y="${zoneTop}" width="${w + lipW * 2}" height="${overflowH}" fill="#f59e0b" opacity="0.06"/>
<rect x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
<rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-lipW}" y="${fillY}" width="${w + lipW * 2}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
</g>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isOverflow ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" style="fill:${isOverflow ? "#10b981" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/>
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#10b981" font-size="10" font-weight="500" opacity="0.8">${inflowLabel}</text>
<text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 10}" y="${lipH + 6}" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${satLabel}</text>
<text x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/>
<rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
<text x="${w / 2}" y="${h + 16}" text-anchor="middle" fill="#34d399" font-size="10" font-weight="500" opacity="0.8">${spendingLabel}</text>
${isOverflow ? `<text x="${-lipW - 4}" y="${lipH + lipNotch / 2 + 3}" text-anchor="end" fill="#6ee7b7" font-size="9" opacity="0.7">${overflowLabel}</text>
<text x="${w + lipW + 4}" y="${lipH + lipNotch / 2 + 3}" text-anchor="start" fill="#6ee7b7" font-size="9" opacity="0.7">${overflowLabel}</text>` : ""}
${this.renderPortsSvg(n)}
</g>`;
}
@ -1240,6 +1345,7 @@ class FolkFlowsApp extends HTMLElement {
fromId: string;
toId: string;
edgeType: string;
waypoint?: { x: number; y: number };
}
const edges: EdgeInfo[] = [];
@ -1255,6 +1361,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#10b981", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "source",
waypoint: alloc.waypoint,
});
}
}
@ -1273,6 +1380,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
});
}
// Spending edges — rate-based drain
@ -1290,6 +1398,7 @@ class FolkFlowsApp extends HTMLElement {
color: "#34d399", flowAmount,
pct: alloc.percentage, dashed: false,
fromId: n.id, toId: alloc.targetId, edgeType: "spending",
waypoint: alloc.waypoint,
});
}
}
@ -1307,27 +1416,27 @@ class FolkFlowsApp extends HTMLElement {
color: "#6ee7b7", flowAmount,
pct: alloc.percentage, dashed: true,
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
waypoint: alloc.waypoint,
});
}
}
}
// Find max flow amount for width normalization
const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount));
// Second pass: render edges with normalized widths
// Second pass: render edges with percentage-proportional widths
const MAX_EDGE_W = 16;
const MIN_EDGE_W = 1.5;
let html = "";
for (const e of edges) {
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
const to = this.getPortPosition(e.toNode, "inflow");
const isGhost = e.flowAmount === 0;
const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14);
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W);
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
e.color, strokeW, e.dashed, isGhost,
label, e.fromId, e.toId, e.edgeType,
e.fromSide,
e.fromSide, e.waypoint,
);
}
return html;
@ -1338,12 +1447,30 @@ class FolkFlowsApp extends HTMLElement {
color: string, strokeW: number, dashed: boolean, ghost: boolean,
label: string, fromId: string, toId: string, edgeType: string,
fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): string {
let d: string;
let midX: number;
let midY: number;
if (fromSide) {
if (waypoint) {
// Cubic Bezier that passes through waypoint at t=0.5:
// P(0.5) = 0.125*P0 + 0.375*C1 + 0.375*C2 + 0.125*P3
// To pass through waypoint W: C1 = (4W - P0 - P3) / 3 blended toward start,
// C2 = (4W - P0 - P3) / 3 blended toward end
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
const cx2 = cx1;
const cy2 = cy1;
// Blend control points to retain start/end tangent direction
const c1x = x1 + (cx1 - x1) * 0.8;
const c1y = y1 + (cy1 - y1) * 0.8;
const c2x = x2 + (cx2 - x2) * 0.8;
const c2y = y2 + (cy2 - y2) * 0.8;
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
midX = waypoint.x;
midY = waypoint.y;
} else if (fromSide) {
// Side port: curve outward horizontally first, then turn toward target
const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60;
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
@ -1357,8 +1484,12 @@ class FolkFlowsApp extends HTMLElement {
midY = (y1 + y2) / 2;
}
// Invisible wide hit area for click/selection
const hitPath = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;
if (ghost) {
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/>
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="-34" y="-12" width="68" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.5"/>
@ -1379,9 +1510,13 @@ class FolkFlowsApp extends HTMLElement {
// Wider label box to fit dollar amounts
const labelW = Math.max(68, label.length * 7 + 36);
const halfW = labelW / 2;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
// Drag handle at midpoint
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
${dragHandle}
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
<text x="0" y="5" fill="${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
@ -1402,6 +1537,38 @@ class FolkFlowsApp extends HTMLElement {
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
}
// ─── Edge waypoint helpers ──────────────────────────────
private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null {
const node = this.nodes.find((n) => n.id === fromId);
if (!node) return null;
if (edgeType === "source" && node.type === "source") {
return (node.data as SourceNodeData).targetAllocations.find((a) => a.targetId === toId) || null;
}
if (edgeType === "overflow") {
if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations.find((a) => a.targetId === toId) || null;
if (node.type === "outcome") return ((node.data as OutcomeNodeData).overflowAllocations || []).find((a) => a.targetId === toId) || null;
}
if (edgeType === "spending" && node.type === "funnel") {
return (node.data as FunnelNodeData).spendingAllocations.find((a) => a.targetId === toId) || null;
}
return null;
}
private setEdgeWaypoint(edgeKey: string, x: number, y: number) {
const [fromId, toId, edgeType] = edgeKey.split("::");
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
if (alloc) alloc.waypoint = { x, y };
}
private removeEdgeWaypoint(fromId: string, toId: string, edgeType: string) {
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
if (alloc) {
delete alloc.waypoint;
this.redrawEdges();
}
}
// ─── Selection highlight ──────────────────────────────
private updateSelectionHighlight() {
@ -1427,6 +1594,18 @@ class FolkFlowsApp extends HTMLElement {
}
}
});
// Edge selection highlight
const edgeLayer = this.shadow.getElementById("edge-layer");
if (!edgeLayer) return;
edgeLayer.querySelectorAll(".edge-group").forEach((g) => {
const el = g as SVGGElement;
const fromId = el.dataset.from;
const toId = el.dataset.to;
const edgeType = el.dataset.edgeType || "source";
const key = `${fromId}::${toId}::${edgeType}`;
el.classList.toggle("edge-group--selected", key === this.selectedEdgeKey);
});
}
private getNodeBorderColor(n: FlowNode): string {

View File

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