Merge branch 'dev'
This commit is contained in:
commit
829e3c722a
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue