Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 20:13:49 -08:00
commit 829e3c722a
3 changed files with 499 additions and 56 deletions

View File

@ -436,6 +436,49 @@
} }
.sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; } .sufficiency-glow { animation: sufficiencyPulse 2s ease-in-out infinite; }
/* ── Funnel shape & inline editing ─────────────────── */
/* Threshold markers in edit mode */
.threshold-marker { pointer-events: none; }
.threshold-handle { cursor: ns-resize; transition: opacity 0.15s; }
.threshold-handle:hover { opacity: 0.8; }
/* Inline edit inputs (foreignObject) */
.inline-edit-input {
background: transparent; border: none; border-bottom: 1px solid #6366f1;
color: #e2e8f0; font-size: 13px; font-weight: 600; width: 100%;
outline: none; padding: 2px 4px; box-sizing: border-box;
font-family: system-ui, -apple-system, sans-serif;
}
.inline-edit-input:focus { border-bottom-color: #818cf8; }
/* Edit mode toolbar */
.inline-edit-toolbar {
display: flex; gap: 4px; justify-content: center; margin-top: 2px;
}
.inline-edit-toolbar button {
padding: 3px 8px; border-radius: 4px; border: none;
font-size: 10px; cursor: pointer; font-weight: 600;
font-family: system-ui, -apple-system, sans-serif;
transition: opacity 0.15s;
}
.inline-edit-toolbar button:hover { opacity: 0.85; }
/* Inline edit overlay container */
.inline-edit-overlay { pointer-events: all; }
/* Funnel overflow lip glow when overflowing */
.funnel-lip { transition: fill 0.3s, opacity 0.3s; }
.funnel-lip--active { fill: #f59e0b; opacity: 0.8; }
/* Status badge in outcome inline edit */
.inline-status-badge { cursor: pointer; transition: opacity 0.15s; }
.inline-status-badge:hover { opacity: 0.8; }
/* Side port arrows */
.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ }
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
/* ── Mobile responsive ──────────────────────────────── */ /* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; } .flows-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }

View File

@ -93,10 +93,17 @@ class FolkFlowsApp extends HTMLElement {
private simInterval: ReturnType<typeof setInterval> | null = null; private simInterval: ReturnType<typeof setInterval> | null = null;
private canvasInitialized = false; private canvasInitialized = false;
// Inline edit state
private inlineEditNodeId: string | null = null;
private inlineEditDragThreshold: string | null = null;
private inlineEditDragStartY = 0;
private inlineEditDragStartValue = 0;
// Wiring state // Wiring state
private wiringActive = false; private wiringActive = false;
private wiringSourceNodeId: string | null = null; private wiringSourceNodeId: string | null = null;
private wiringSourcePortKind: PortKind | null = null; private wiringSourcePortKind: PortKind | null = null;
private wiringSourcePortSide: "left" | "right" | null = null;
private wiringDragging = false; private wiringDragging = false;
private wiringPointerX = 0; private wiringPointerX = 0;
private wiringPointerY = 0; private wiringPointerY = 0;
@ -653,7 +660,13 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } { private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") return { w: 200, h: 60 }; if (n.type === "source") return { w: 200, h: 60 };
if (n.type === "funnel") return { w: 220, h: 180 }; if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const baseW = 200, baseH = 160;
// Scale: $1k/mo = 1x, $10k/mo = ~1.3x, $100k/mo = ~1.6x (logarithmic)
const scale = 1 + Math.log10(Math.max(1, d.inflowRate / 1000)) * 0.3;
return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) };
}
return { w: 200, h: 100 }; // outcome return { w: 200, h: 100 }; // outcome
} }
@ -766,9 +779,10 @@ class FolkFlowsApp extends HTMLElement {
nodeDragStarted = false; nodeDragStarted = false;
svg.classList.remove("dragging"); svg.classList.remove("dragging");
// If it was a click (no drag), open the editor // Single click = select only (inline edit on double-click)
if (!wasDragged) { if (!wasDragged) {
this.openEditor(clickedNodeId); this.selectedNodeId = clickedNodeId;
this.updateSelectionHighlight();
} }
} }
}; };
@ -799,7 +813,8 @@ class FolkFlowsApp extends HTMLElement {
// Start wiring from output port // Start wiring from output port
if (portDir === "out") { if (portDir === "out") {
this.enterWiring(portNodeId, portKind); const portSide = portGroup.dataset.portSide as "left" | "right" | undefined;
this.enterWiring(portNodeId, portKind, portSide);
this.wiringDragging = true; this.wiringDragging = true;
this.wiringPointerX = e.clientX; this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY; this.wiringPointerY = e.clientY;
@ -844,9 +859,7 @@ class FolkFlowsApp extends HTMLElement {
if (!nodeId) return; if (!nodeId) return;
const node = this.nodes.find((n) => n.id === nodeId); const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return; if (!node) return;
if (node.type === "outcome") this.openOutcomeModal(nodeId); this.enterInlineEdit(nodeId);
else if (node.type === "source") this.openSourceModal(nodeId);
else this.openEditor(nodeId);
}); });
// Hover: tooltip + edge highlighting // Hover: tooltip + edge highlighting
@ -974,6 +987,7 @@ class FolkFlowsApp extends HTMLElement {
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "Escape") { if (e.key === "Escape") {
if (this.inlineEditNodeId) { this.exitInlineEdit(); return; }
if (this.wiringActive) { this.cancelWiring(); return; } if (this.wiringActive) { this.cancelWiring(); return; }
this.closeModal(); this.closeModal();
this.closeEditor(); this.closeEditor();
@ -1065,9 +1079,11 @@ class FolkFlowsApp extends HTMLElement {
private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string { private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const x = n.position.x, y = n.position.y, w = 220, h = 180; const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const sufficiency = computeSufficiencyState(d); const sufficiency = computeSufficiencyState(d);
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant"; const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
const isAbundant = sufficiency === "abundant";
const threshold = d.sufficientThreshold ?? d.maxThreshold; const threshold = d.sufficientThreshold ?? d.maxThreshold;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
@ -1079,46 +1095,89 @@ class FolkFlowsApp extends HTMLElement {
: sufficiency === "sufficient" ? "Sufficient" : sufficiency === "sufficient" ? "Sufficient"
: d.currentValue < d.minThreshold ? "Critical" : "Seeking"; : d.currentValue < d.minThreshold ? "Critical" : "Seeking";
// Inflow satisfaction bar // Funnel shape parameters
const satBarY = 28; const r = 10; // corner radius
const satBarW = w - 20; const lipW = 14; // overflow lip extension
const satRatio = sat ? Math.min(sat.ratio, 1) : 0; const lipH = Math.round(h * 0.08); // lip notch top offset
const satOverflow = sat ? sat.ratio > 1 : false; const lipNotch = 14; // lip notch height
const satFillW = satBarW * satRatio; const taperStart = 0.65; // body tapers at 65% down
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; const taperInset = 0.2; // bottom is 60% of top width
const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : ""; const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`;
// 3-zone background: drain (red), healthy (blue), overflow (amber) // Funnel SVG path: wide top with lip notches, tapering to narrow bottom
const zoneY = 52; const funnelPath = [
const zoneH = h - 76; `M ${r},0`, // top-left after corner
`L ${w - r},0`, // across top
`Q ${w},0 ${w},${r}`, // top-right corner
`L ${w},${lipH}`, // down to right lip
`L ${w + lipW},${lipH}`, // right lip extends
`L ${w + lipW},${lipH + lipNotch}`, // right lip bottom
`L ${w},${lipH + lipNotch}`, // back to body
`L ${w},${taperY}`, // down right side to taper
`Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`, // taper curve right
`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
`L 0,${lipH + lipNotch}`, // up left side from taper
`L ${-lipW},${lipH + lipNotch}`, // left lip bottom
`L ${-lipW},${lipH}`, // left lip top
`L 0,${lipH}`, // back to body
`L 0,${r}`, // up to top-left
`Q 0,0 ${r},0`, // top-left corner
`Z`,
].join(" ");
// Interior regions (clipped to funnel shape)
const zoneTop = lipH + lipNotch + 4;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const drainPct = d.minThreshold / (d.maxCapacity || 1); const drainPct = d.minThreshold / (d.maxCapacity || 1);
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1); const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
const overflowPct = 1 - drainPct - healthyPct; const overflowPct = Math.max(0, 1 - drainPct - healthyPct);
const drainH = zoneH * drainPct; const drainH = zoneH * drainPct;
const healthyH = zoneH * healthyPct; const healthyH = zoneH * healthyPct;
const overflowH = zoneH * overflowPct; const overflowH = zoneH * overflowPct;
// Fill level // Fill level
const totalFillH = zoneH * fillPct; const totalFillH = zoneH * fillPct;
const fillY = zoneY + zoneH - totalFillH; const fillY = zoneTop + zoneH - totalFillH;
// Inflow satisfaction bar
const satBarY = lipH + lipNotch + 22;
const satBarW = w - 40;
const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
const satOverflow = sat ? sat.ratio > 1 : false;
const satFillW = satBarW * satRatio;
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : "";
const glowClass = isSufficient ? " node-glow" : ""; const glowClass = isSufficient ? " node-glow" : "";
return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})"> return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
${isSufficient ? `<rect x="-3" y="-3" width="${w + 6}" height="${h + 6}" rx="14" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4"/>` : ""} <defs>
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="10" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/> <clipPath id="${clipId}"><path d="${funnelPath}"/></clipPath>
<text x="10" y="18" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text> </defs>
<text x="${w - 10}" y="18" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? 'sufficiency-glow' : ''}">${statusLabel}</text> ${isSufficient ? `<path d="${funnelPath}" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<rect x="10" y="${satBarY}" width="${satBarW}" height="6" rx="3" fill="#334155" opacity="0.3" class="satisfaction-bar-bg"/> <path class="node-bg" d="${funnelPath}" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
<rect x="10" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/> <g clip-path="url(#${clipId})">
<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"/>
</g>
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${-lipW}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
<rect class="funnel-lip ${isAbundant ? "funnel-lip--active" : ""}" x="${w}" y="${lipH}" width="${lipW}" height="${lipNotch}" rx="2" fill="${isAbundant ? "#f59e0b" : "#334155"}" opacity="${isAbundant ? 0.8 : 0.3}"/>
<text x="${w / 2}" y="${lipH + 6}" text-anchor="middle" fill="#e2e8f0" 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="${isSufficient ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" fill="#334155" 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" fill="#64748b" font-size="9">${satLabel}</text> <text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" fill="#64748b" font-size="9">${satLabel}</text>
<rect x="2" y="${zoneY + overflowH + healthyH}" width="${w - 4}" height="${drainH}" fill="#ef4444" opacity="0.08" rx="0"/> <text x="${w / 2}" y="${h - insetPx - 8}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="2" y="${zoneY + overflowH}" width="${w - 4}" height="${healthyH}" fill="#0ea5e9" opacity="0.06" rx="0"/> <rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" fill="#334155"/>
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/> <rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
@ -1186,6 +1245,7 @@ class FolkFlowsApp extends HTMLElement {
fromNode: FlowNode; fromNode: FlowNode;
toNode: FlowNode; toNode: FlowNode;
fromPort: PortKind; fromPort: PortKind;
fromSide?: "left" | "right";
color: string; color: string;
flowAmount: number; flowAmount: number;
pct: number; pct: number;
@ -1213,14 +1273,16 @@ class FolkFlowsApp extends HTMLElement {
} }
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
// Overflow edges — actual excess flow // Overflow edges — actual excess flow (routed through side ports)
for (const alloc of d.overflowAllocations) { for (const alloc of d.overflowAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId); const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue; if (!target) continue;
const excess = Math.max(0, d.currentValue - d.maxThreshold); const excess = Math.max(0, d.currentValue - d.maxThreshold);
const flowAmount = excess * (alloc.percentage / 100); const flowAmount = excess * (alloc.percentage / 100);
const side = this.getOverflowSideForTarget(n, target);
edges.push({ edges.push({
fromNode: n, toNode: target, fromPort: "overflow", fromNode: n, toNode: target, fromPort: "overflow",
fromSide: side,
color: alloc.color || "#f59e0b", flowAmount, color: alloc.color || "#f59e0b", 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",
@ -1269,7 +1331,7 @@ class FolkFlowsApp extends HTMLElement {
// Second pass: render edges with normalized widths // 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); 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 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14);
@ -1278,6 +1340,7 @@ class FolkFlowsApp extends HTMLElement {
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,
); );
} }
return html; return html;
@ -1287,12 +1350,25 @@ class FolkFlowsApp extends HTMLElement {
x1: number, y1: number, x2: number, y2: number, x1: number, y1: number, x2: number, y2: number,
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",
): string { ): string {
const cy1 = y1 + (y2 - y1) * 0.4; let d: string;
const cy2 = y1 + (y2 - y1) * 0.6; let midX: number;
const midX = (x1 + x2) / 2; let midY: number;
const midY = (y1 + y2) / 2;
const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`; 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}`;
midX = (x1 + outwardX + x2) / 3;
midY = (y1 + y2) / 2;
} else {
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
midX = (x1 + x2) / 2;
midY = (y1 + y2) / 2;
}
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}">
@ -1348,7 +1424,7 @@ class FolkFlowsApp extends HTMLElement {
const el = g as SVGGElement; const el = g as SVGGElement;
const isSelected = el.dataset.nodeId === this.selectedNodeId; const isSelected = el.dataset.nodeId === this.selectedNodeId;
el.classList.toggle("selected", isSelected); el.classList.toggle("selected", isSelected);
const bg = el.querySelector(".node-bg") as SVGRectElement | null; const bg = el.querySelector(".node-bg") as SVGElement | null;
if (bg) { if (bg) {
if (isSelected) { if (isSelected) {
bg.setAttribute("stroke", "#6366f1"); bg.setAttribute("stroke", "#6366f1");
@ -1386,23 +1462,51 @@ class FolkFlowsApp extends HTMLElement {
return PORT_DEFS[nodeType] || []; return PORT_DEFS[nodeType] || [];
} }
private getPortPosition(node: FlowNode, portKind: PortKind): { x: number; y: number } { private getPortPosition(node: FlowNode, portKind: PortKind, side?: "left" | "right"): { x: number; y: number } {
const s = this.getNodeSize(node); const s = this.getNodeSize(node);
const def = this.getPortDefs(node.type).find((p) => p.kind === portKind); let def: PortDefinition | undefined;
if (side) {
def = this.getPortDefs(node.type).find((p) => p.kind === portKind && p.side === side);
}
if (!def) {
def = this.getPortDefs(node.type).find((p) => p.kind === portKind && (!side || !p.side));
}
if (!def) {
// Fallback: pick first matching kind
def = this.getPortDefs(node.type).find((p) => p.kind === portKind);
}
if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 };
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
} }
/** Pick the overflow side port closest to a target node */
private getOverflowSideForTarget(fromNode: FlowNode, toNode: FlowNode): "left" | "right" {
const toCenter = toNode.position.x + this.getNodeSize(toNode).w / 2;
const fromCenter = fromNode.position.x + this.getNodeSize(fromNode).w / 2;
return toCenter < fromCenter ? "left" : "right";
}
private renderPortsSvg(n: FlowNode): string { private renderPortsSvg(n: FlowNode): string {
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type); const defs = this.getPortDefs(n.type);
return defs.map((p) => { return defs.map((p) => {
const cx = s.w * p.xFrac; const cx = s.w * p.xFrac;
const cy = s.h * p.yFrac; const cy = s.h * p.yFrac;
const arrow = p.dir === "out" let arrow: string;
? `<path d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" fill="${p.color}" opacity="0.7"/>` const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
: `<path d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" fill="${p.color}" opacity="0.7"/>`; if (p.side) {
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"> // Side port: horizontal arrow
if (p.side === "left") {
arrow = `<path class="port-arrow" d="M ${cx - 4} ${cy - 3} l -4 3 l 4 3" fill="${p.color}" opacity="0.7"/>`;
} else {
arrow = `<path class="port-arrow" d="M ${cx + 4} ${cy - 3} l 4 3 l -4 3" fill="${p.color}" opacity="0.7"/>`;
}
} else if (p.dir === "out") {
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" fill="${p.color}" opacity="0.7"/>`;
} else {
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" fill="${p.color}" opacity="0.7"/>`;
}
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"${sideAttr}>
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/> <circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" fill="${p.color}" style="color:${p.color}"/> <circle class="port-dot" cx="${cx}" cy="${cy}" r="5" fill="${p.color}" style="color:${p.color}"/>
${arrow} ${arrow}
@ -1410,10 +1514,11 @@ class FolkFlowsApp extends HTMLElement {
}).join(""); }).join("");
} }
private enterWiring(nodeId: string, portKind: PortKind) { private enterWiring(nodeId: string, portKind: PortKind, portSide?: "left" | "right") {
this.wiringActive = true; this.wiringActive = true;
this.wiringSourceNodeId = nodeId; this.wiringSourceNodeId = nodeId;
this.wiringSourcePortKind = portKind; this.wiringSourcePortKind = portKind;
this.wiringSourcePortSide = portSide || null;
this.wiringDragging = false; this.wiringDragging = false;
const svg = this.shadow.getElementById("flow-canvas"); const svg = this.shadow.getElementById("flow-canvas");
if (svg) svg.classList.add("wiring"); if (svg) svg.classList.add("wiring");
@ -1424,6 +1529,7 @@ class FolkFlowsApp extends HTMLElement {
this.wiringActive = false; this.wiringActive = false;
this.wiringSourceNodeId = null; this.wiringSourceNodeId = null;
this.wiringSourcePortKind = null; this.wiringSourcePortKind = null;
this.wiringSourcePortSide = null;
this.wiringDragging = false; this.wiringDragging = false;
const svg = this.shadow.getElementById("flow-canvas"); const svg = this.shadow.getElementById("flow-canvas");
if (svg) svg.classList.remove("wiring"); if (svg) svg.classList.remove("wiring");
@ -1534,7 +1640,7 @@ class FolkFlowsApp extends HTMLElement {
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId); const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
if (!sourceNode) return; if (!sourceNode) return;
const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind); const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind, this.wiringSourcePortSide || undefined);
const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null; const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
if (!svg) return; if (!svg) return;
@ -1542,9 +1648,17 @@ class FolkFlowsApp extends HTMLElement {
const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom; const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom; const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
const cy1 = y1 + (y2 - y1) * 0.4; let tempPath: string;
const cy2 = y1 + (y2 - y1) * 0.6; if (this.wiringSourcePortSide) {
wireLayer.innerHTML = `<path class="wiring-temp-path" d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}"/>`; // Side port: curve outward horizontally first
const outwardX = this.wiringSourcePortSide === "left" ? x1 - 60 : x1 + 60;
tempPath = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
} else {
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
tempPath = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
}
wireLayer.innerHTML = `<path class="wiring-temp-path" d="${tempPath}"/>`;
} }
// ─── Node position update (direct DOM, no re-render) ── // ─── Node position update (direct DOM, no re-render) ──
@ -1637,6 +1751,289 @@ class FolkFlowsApp extends HTMLElement {
if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; } if (panel) { panel.classList.remove("open"); panel.innerHTML = ""; }
} }
// ─── Inline edit mode ─────────────────────────────────
private enterInlineEdit(nodeId: string) {
// Exit any previous inline edit
if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) {
this.exitInlineEdit();
}
this.inlineEditNodeId = nodeId;
this.selectedNodeId = nodeId;
this.updateSelectionHighlight();
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
// Overlay the inline edit SVG elements on the node
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null;
if (!g) return;
// Remove any existing inline edit overlay
g.querySelector(".inline-edit-overlay")?.remove();
const s = this.getNodeSize(node);
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
overlay.classList.add("inline-edit-overlay");
if (node.type === "funnel") {
this.renderFunnelInlineEdit(overlay, node, s);
} else if (node.type === "source") {
this.renderSourceInlineEdit(overlay, node, s);
} else {
this.renderOutcomeInlineEdit(overlay, node, s);
}
// Toolbar: Done | Delete | ...
const toolbarY = s.h + 8;
overlay.innerHTML += `
<foreignObject x="${s.w / 2 - 80}" y="${toolbarY}" width="160" height="28">
<div xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-toolbar">
<button class="iet-done" style="background:#10b981;color:white">Done</button>
<button class="iet-delete" style="background:#ef4444;color:white">Delete</button>
<button class="iet-panel" style="background:#334155;color:#e2e8f0">...</button>
</div>
</foreignObject>`;
g.appendChild(overlay);
this.attachInlineEditListeners(g, node);
}
private renderFunnelInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
const d = node.data as FunnelNodeData;
const lipH = Math.round(s.h * 0.08);
const lipNotch = 14;
const zoneTop = lipH + lipNotch + 4;
const zoneBot = s.h - 4;
const zoneH = zoneBot - zoneTop;
// Label edit
overlay.innerHTML = `
<foreignObject x="10" y="${lipH - 4}" width="${s.w - 20}" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-input" data-inline-field="label" value="${this.esc(d.label)}"/>
</foreignObject>`;
// Threshold markers
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "#ef4444", label: "Min" },
{ key: "maxThreshold", value: d.maxThreshold, color: "#f59e0b", label: "Max" },
];
if (d.sufficientThreshold !== undefined) {
thresholds.push({ key: "sufficientThreshold", value: d.sufficientThreshold, color: "#10b981", label: "Suf" });
}
for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac);
overlay.innerHTML += `
<line class="threshold-marker" x1="8" x2="${s.w - 8}" y1="${markerY}" y2="${markerY}" stroke="${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
<rect class="threshold-handle" x="${s.w - 56}" y="${markerY - 9}" width="52" height="18" rx="4" fill="${t.color}" data-threshold="${t.key}" style="cursor:ns-resize"/>
<text x="${s.w - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
}
}
private renderSourceInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
const d = node.data as SourceNodeData;
overlay.innerHTML = `
<foreignObject x="36" y="8" width="${s.w - 46}" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-input" data-inline-field="label" value="${this.esc(d.label)}"/>
</foreignObject>
<foreignObject x="${s.w / 2 - 50}" y="32" width="100" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-input" data-inline-field="flowRate" type="number" value="${d.flowRate}" style="text-align:center;font-size:11px"/>
</foreignObject>`;
}
private renderOutcomeInlineEdit(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
const d = node.data as OutcomeNodeData;
const statusColors: Record<string, string> = {
"not-started": "#64748b", "in-progress": "#3b82f6", "completed": "#10b981", "blocked": "#ef4444"
};
const statusList = ["not-started", "in-progress", "completed", "blocked"] as const;
const nextStatus = statusList[(statusList.indexOf(d.status) + 1) % statusList.length];
overlay.innerHTML = `
<foreignObject x="26" y="6" width="${s.w - 36}" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-input" data-inline-field="label" value="${this.esc(d.label)}"/>
</foreignObject>
<foreignObject x="10" y="${s.h - 30}" width="${s.w - 20}" height="22">
<input xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-input" data-inline-field="fundingTarget" type="number" value="${d.fundingTarget}" style="text-align:center;font-size:11px"/>
</foreignObject>
<rect class="inline-status-badge" x="${s.w - 50}" y="56" width="40" height="16" rx="4" fill="${statusColors[d.status]}" data-inline-action="cycle-status" style="cursor:pointer"/>
<text x="${s.w - 30}" y="68" fill="white" font-size="8" text-anchor="middle" pointer-events="none">${d.status}</text>`;
}
private attachInlineEditListeners(g: SVGGElement, node: FlowNode) {
const overlay = g.querySelector(".inline-edit-overlay");
if (!overlay) return;
// Input fields
overlay.querySelectorAll("input[data-inline-field]").forEach((el) => {
const input = el as HTMLInputElement;
const field = input.dataset.inlineField!;
input.addEventListener("input", () => {
const val = input.type === "number" ? parseFloat(input.value) || 0 : input.value;
(node.data as any)[field] = val;
// Re-render the node (but not the overlay)
this.redrawNodeOnly(node);
this.redrawEdges();
});
input.addEventListener("keydown", (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") this.exitInlineEdit();
if (ke.key === "Escape") this.exitInlineEdit();
ke.stopPropagation();
});
});
// Threshold drag handles
overlay.querySelectorAll(".threshold-handle").forEach((el) => {
el.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const thresholdKey = (el as SVGElement).dataset.threshold!;
this.inlineEditDragThreshold = thresholdKey;
this.inlineEditDragStartY = pe.clientY;
this.inlineEditDragStartValue = (node.data as any)[thresholdKey] || 0;
(el as Element).setPointerCapture(pe.pointerId);
});
el.addEventListener("pointermove", (e: Event) => {
if (!this.inlineEditDragThreshold) return;
const pe = e as PointerEvent;
const d = node.data as FunnelNodeData;
const s = this.getNodeSize(node);
const lipH = Math.round(s.h * 0.08);
const lipNotch = 14;
const zoneH = s.h - 4 - (lipH + lipNotch + 4);
// Pixels to dollar conversion
const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
let newVal = this.inlineEditDragStartValue + deltaDollars;
// Constrain: 0 ≤ min ≤ sufficient ≤ max ≤ capacity
newVal = Math.max(0, Math.min(d.maxCapacity, newVal));
const key = this.inlineEditDragThreshold;
if (key === "minThreshold") newVal = Math.min(newVal, d.maxThreshold);
if (key === "maxThreshold") newVal = Math.max(newVal, d.minThreshold);
if (key === "sufficientThreshold") newVal = Math.max(d.minThreshold, Math.min(d.maxThreshold, newVal));
(node.data as any)[key] = Math.round(newVal);
// Update display
this.redrawNodeInlineEdit(node);
});
el.addEventListener("pointerup", () => {
this.inlineEditDragThreshold = null;
});
});
// Done button
overlay.querySelector(".iet-done")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
});
// Delete button
overlay.querySelector(".iet-delete")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.deleteNode(node.id);
this.exitInlineEdit();
});
// "..." panel fallback button
overlay.querySelector(".iet-panel")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
this.exitInlineEdit();
this.openEditor(node.id);
});
// Status badge cycling (outcome)
overlay.querySelector("[data-inline-action='cycle-status']")?.addEventListener("click", (e: Event) => {
e.stopPropagation();
const d = node.data as OutcomeNodeData;
const statusList = ["not-started", "in-progress", "completed", "blocked"] as const;
d.status = statusList[(statusList.indexOf(d.status) + 1) % statusList.length];
this.redrawNodeInlineEdit(node);
});
// Click-outside handler to exit inline edit
const clickOutsideHandler = (e: PointerEvent) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`)) {
this.exitInlineEdit();
document.removeEventListener("pointerdown", clickOutsideHandler as EventListener, true);
}
};
setTimeout(() => {
document.addEventListener("pointerdown", clickOutsideHandler as EventListener, true);
}, 100);
}
private redrawNodeOnly(node: FlowNode) {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
const satisfaction = this.computeInflowSatisfaction();
const newSvg = this.renderNodeSvg(node, satisfaction);
// Parse and replace, preserving inline edit overlay
const overlay = g.querySelector(".inline-edit-overlay");
const temp = document.createElementNS("http://www.w3.org/2000/svg", "g");
temp.innerHTML = newSvg;
const newG = temp.firstElementChild as SVGGElement;
if (newG && overlay) {
newG.appendChild(overlay);
}
if (newG) {
g.replaceWith(newG);
}
}
private redrawNodeInlineEdit(node: FlowNode) {
// Re-render the whole node + re-enter inline edit
this.drawCanvasContent();
const s = this.getNodeSize(node);
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
if (!g) return;
g.querySelector(".inline-edit-overlay")?.remove();
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
overlay.classList.add("inline-edit-overlay");
if (node.type === "funnel") {
this.renderFunnelInlineEdit(overlay, node, s);
} else if (node.type === "source") {
this.renderSourceInlineEdit(overlay, node, s);
} else {
this.renderOutcomeInlineEdit(overlay, node, s);
}
// Toolbar
const toolbarY = s.h + 8;
overlay.innerHTML += `
<foreignObject x="${s.w / 2 - 80}" y="${toolbarY}" width="160" height="28">
<div xmlns="http://www.w3.org/1999/xhtml" class="inline-edit-toolbar">
<button class="iet-done" style="background:#10b981;color:white">Done</button>
<button class="iet-delete" style="background:#ef4444;color:white">Delete</button>
<button class="iet-panel" style="background:#334155;color:#e2e8f0">...</button>
</div>
</foreignObject>`;
g.appendChild(overlay);
this.attachInlineEditListeners(g, node);
}
private exitInlineEdit() {
if (!this.inlineEditNodeId) return;
const nodeLayer = this.shadow.getElementById("node-layer");
const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`) as SVGGElement | null;
if (g) g.querySelector(".inline-edit-overlay")?.remove();
this.inlineEditNodeId = null;
this.inlineEditDragThreshold = null;
// Re-render to apply any changes
this.drawCanvasContent();
}
private refreshEditorIfOpen(nodeId: string) { private refreshEditorIfOpen(nodeId: string) {
if (this.editingNodeId === nodeId) this.openEditor(nodeId); if (this.editingNodeId === nodeId) this.openEditor(nodeId);
} }

View File

@ -109,6 +109,8 @@ export interface PortDefinition {
color: string; color: string;
/** Which port kinds this output can wire to */ /** Which port kinds this output can wire to */
connectsTo?: PortKind[]; connectsTo?: PortKind[];
/** For side-mounted ports (overflow lips) */
side?: "left" | "right";
} }
/** Single source of truth for port positions, colors, and connectivity rules. */ /** Single source of truth for port positions, colors, and connectivity rules. */
@ -117,9 +119,10 @@ export const PORT_DEFS: Record<FlowNode["type"], PortDefinition[]> = {
{ kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] }, { kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
], ],
funnel: [ funnel: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },
{ kind: "spending", dir: "out", xFrac: 0.3, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "left" },
{ kind: "overflow", dir: "out", xFrac: 0.7, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] }, { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.15, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
{ kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] },
], ],
outcome: [ outcome: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },