Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-02 14:30:25 -08:00
commit a9ebba03cb
4 changed files with 403 additions and 37 deletions

View File

@ -11,7 +11,8 @@
* mode "demo" to use hardcoded demo data (no API)
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData } from "../lib/types";
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind } 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";
import { mapFlowToNodes } from "../lib/map-flow";
@ -92,6 +93,14 @@ class FolkFundsApp extends HTMLElement {
private simInterval: ReturnType<typeof setInterval> | null = null;
private canvasInitialized = false;
// Wiring state
private wiringActive = false;
private wiringSourceNodeId: string | null = null;
private wiringSourcePortKind: PortKind | null = null;
private wiringDragging = false;
private wiringPointerX = 0;
private wiringPointerY = 0;
// Bound handlers for cleanup
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
@ -565,6 +574,7 @@ class FolkFundsApp extends HTMLElement {
<svg class="funds-canvas-svg" id="flow-canvas">
<g id="canvas-transform">
<g id="edge-layer"></g>
<g id="wire-layer"></g>
<g id="node-layer"></g>
</g>
</svg>
@ -668,6 +678,10 @@ class FolkFundsApp extends HTMLElement {
// Only pan when clicking SVG background (not on a node)
if (target.closest(".flow-node")) return;
if (target.closest(".edge-ctrl-group")) return;
// Cancel wiring on empty canvas click
if (this.wiringActive) { this.cancelWiring(); return; }
this.isPanning = true;
this.panStartX = e.clientX;
this.panStartY = e.clientY;
@ -685,6 +699,12 @@ class FolkFundsApp extends HTMLElement {
// Global pointer move/up (for both panning and node drag)
this._boundPointerMove = (e: PointerEvent) => {
if (this.wiringActive && this.wiringDragging) {
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
this.updateWiringTempLine();
return;
}
if (this.isPanning) {
this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
@ -704,6 +724,20 @@ class FolkFundsApp extends HTMLElement {
}
};
this._boundPointerUp = (e: PointerEvent) => {
if (this.wiringActive && this.wiringDragging) {
// Hit-test: did we release on a compatible input port?
const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
const portGroup = el?.closest?.(".port-group") as SVGGElement | null;
if (portGroup && portGroup.dataset.portDir === "in" && portGroup.dataset.nodeId !== this.wiringSourceNodeId) {
this.completeWiring(portGroup.dataset.nodeId!);
} else {
// Fall back to click-to-wire mode (source still glowing)
this.wiringDragging = false;
const wireLayer = this.shadow.getElementById("wire-layer");
if (wireLayer) wireLayer.innerHTML = "";
}
return;
}
if (this.isPanning) {
this.isPanning = false;
svg.classList.remove("panning");
@ -720,6 +754,36 @@ class FolkFundsApp extends HTMLElement {
const nodeLayer = this.shadow.getElementById("node-layer");
if (nodeLayer) {
nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
// Check port interaction FIRST
const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null;
if (portGroup) {
e.stopPropagation();
const portNodeId = portGroup.dataset.nodeId!;
const portKind = portGroup.dataset.portKind as PortKind;
const portDir = portGroup.dataset.portDir!;
if (this.wiringActive) {
// Click-to-wire: complete on compatible input port
if (portDir === "in" && portNodeId !== this.wiringSourceNodeId) {
this.completeWiring(portNodeId);
} else {
this.cancelWiring();
}
return;
}
// Start wiring from output port
if (portDir === "out") {
this.enterWiring(portNodeId, portKind);
this.wiringDragging = true;
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
svg.setPointerCapture(e.pointerId);
return;
}
return;
}
const group = (e.target as Element).closest(".flow-node") as SVGGElement | null;
if (!group) return;
e.stopPropagation();
@ -728,6 +792,12 @@ class FolkFundsApp extends HTMLElement {
const node = this.nodes.find((n) => n.id === nodeId);
if (!node) return;
// If wiring is active and clicked on a node (not port), cancel
if (this.wiringActive) {
this.cancelWiring();
return;
}
// Select
this.selectedNodeId = nodeId;
this.updateSelectionHighlight();
@ -786,12 +856,15 @@ class FolkFundsApp extends HTMLElement {
const tag = (e.target as Element).tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
if (e.key === "Escape") {
if (this.wiringActive) { this.cancelWiring(); return; }
this.closeEditor();
}
else if (e.key === " ") { e.preventDefault(); this.toggleSimulation(); }
else if (e.key === "Delete" || e.key === "Backspace") { if (this.selectedNodeId) this.deleteNode(this.selectedNodeId); }
else if (e.key === "f" || e.key === "F") this.fitView();
else if (e.key === "=" || e.key === "+") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
else if (e.key === "-") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
else if (e.key === "Escape") this.closeEditor();
};
document.addEventListener("keydown", this._boundKeyDown);
}
@ -820,6 +893,7 @@ class FolkFundsApp extends HTMLElement {
<text x="36" y="24" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="46" text-anchor="middle" fill="#6ee7b7" font-size="11">$${d.flowRate.toLocaleString()}/mo</text>
${this.renderAllocBar(d.targetAllocations, w, h - 6)}
${this.renderPortsSvg(n)}
</g>`;
}
@ -867,6 +941,7 @@ class FolkFundsApp extends HTMLElement {
<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)}
</g>`;
}
@ -896,6 +971,7 @@ class FolkFundsApp extends HTMLElement {
<rect x="10" y="34" width="${(w - 20) * fillPct}" height="5" rx="2.5" fill="${statusColor}" opacity="0.8"/>
<text x="${w / 2}" y="52" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% $${Math.floor(d.fundingReceived).toLocaleString()}</text>
${phaseBars}
${this.renderPortsSvg(n)}
</g>`;
}
@ -919,19 +995,17 @@ class FolkFundsApp extends HTMLElement {
// Find max flow rate for Sankey width scaling
const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate));
// Source → target edges
for (const n of this.nodes) {
if (n.type === "source") {
const d = n.data as SourceNodeData;
const s = this.getNodeSize(n);
const from = this.getPortPosition(n, "outflow");
for (const alloc of d.targetAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue;
const ts = this.getNodeSize(target);
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 8);
html += this.renderEdgePath(
n.position.x + s.w / 2, n.position.y + s.h,
target.position.x + ts.w / 2, target.position.y,
from.x, from.y, to.x, to.y,
alloc.color || "#10b981", strokeW, false,
alloc.percentage, n.id, alloc.targetId, "source",
);
@ -939,34 +1013,50 @@ class FolkFundsApp extends HTMLElement {
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
// Overflow edges
// Overflow edges — from overflow port
for (const alloc of d.overflowAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue;
const ts = this.getNodeSize(target);
const from = this.getPortPosition(n, "overflow");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 6);
html += this.renderEdgePath(
n.position.x + s.w / 2, n.position.y + s.h,
target.position.x + ts.w / 2, target.position.y,
from.x, from.y, to.x, to.y,
alloc.color || "#f59e0b", strokeW, true,
alloc.percentage, n.id, alloc.targetId, "overflow",
);
}
// Spending edges
// Spending edges — from spending port
for (const alloc of d.spendingAllocations) {
const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue;
const ts = this.getNodeSize(target);
const from = this.getPortPosition(n, "spending");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
html += this.renderEdgePath(
n.position.x + s.w / 2, n.position.y + s.h,
target.position.x + ts.w / 2, target.position.y,
from.x, from.y, to.x, to.y,
alloc.color || "#8b5cf6", strokeW, false,
alloc.percentage, n.id, alloc.targetId, "spending",
);
}
}
// Outcome overflow edges
if (n.type === "outcome") {
const d = n.data as OutcomeNodeData;
const allocs = d.overflowAllocations || [];
for (const alloc of allocs) {
const target = this.nodes.find((t) => t.id === alloc.targetId);
if (!target) continue;
const from = this.getPortPosition(n, "overflow");
const to = this.getPortPosition(target, "inflow");
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 5);
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
alloc.color || "#f59e0b", strokeW, true,
alloc.percentage, n.id, alloc.targetId, "overflow",
);
}
}
}
return html;
}
@ -1044,6 +1134,173 @@ class FolkFundsApp extends HTMLElement {
return d.status === "completed" ? "#10b981" : d.status === "blocked" ? "#ef4444" : d.status === "in-progress" ? "#3b82f6" : "#64748b";
}
// ─── Port rendering & wiring ─────────────────────────
private getPortDefs(nodeType: FlowNode["type"]): PortDefinition[] {
return PORT_DEFS[nodeType] || [];
}
private getPortPosition(node: FlowNode, portKind: PortKind): { x: number; y: number } {
const s = this.getNodeSize(node);
const 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 };
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
}
private renderPortsSvg(n: FlowNode): string {
const s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type);
return defs.map((p) => {
const cx = s.w * p.xFrac;
const cy = s.h * p.yFrac;
const arrow = p.dir === "out"
? `<path d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -8 : 4)} l 3 4 l 3 -4" fill="${p.color}" opacity="0.7"/>`
: `<path 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}">
<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}"/>
${arrow}
</g>`;
}).join("");
}
private enterWiring(nodeId: string, portKind: PortKind) {
this.wiringActive = true;
this.wiringSourceNodeId = nodeId;
this.wiringSourcePortKind = portKind;
this.wiringDragging = false;
const svg = this.shadow.getElementById("flow-canvas");
if (svg) svg.classList.add("wiring");
this.applyWiringClasses();
}
private cancelWiring() {
this.wiringActive = false;
this.wiringSourceNodeId = null;
this.wiringSourcePortKind = null;
this.wiringDragging = false;
const svg = this.shadow.getElementById("flow-canvas");
if (svg) svg.classList.remove("wiring");
const wireLayer = this.shadow.getElementById("wire-layer");
if (wireLayer) wireLayer.innerHTML = "";
this.clearWiringClasses();
}
private applyWiringClasses() {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
if (!sourceNode) return;
const sourceDef = this.getPortDefs(sourceNode.type).find((p) => p.kind === this.wiringSourcePortKind);
const connectsTo = sourceDef?.connectsTo || [];
nodeLayer.querySelectorAll(".port-group").forEach((g) => {
const el = g as SVGGElement;
const nid = el.dataset.nodeId!;
const pk = el.dataset.portKind as PortKind;
const pd = el.dataset.portDir!;
if (nid === this.wiringSourceNodeId && pk === this.wiringSourcePortKind) {
el.classList.add("port-group--wiring-source");
} else if (pd === "in" && connectsTo.includes(pk) && nid !== this.wiringSourceNodeId && !this.allocationExists(this.wiringSourceNodeId!, nid, this.wiringSourcePortKind!)) {
el.classList.add("port-group--wiring-target");
} else {
el.classList.add("port-group--wiring-dimmed");
}
});
}
private clearWiringClasses() {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
nodeLayer.querySelectorAll(".port-group").forEach((g) => {
g.classList.remove("port-group--wiring-source", "port-group--wiring-target", "port-group--wiring-dimmed");
});
}
private completeWiring(targetNodeId: string) {
if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
const targetNode = this.nodes.find((n) => n.id === targetNodeId);
if (!sourceNode || !targetNode) { this.cancelWiring(); return; }
// Determine allocation type and color
const portKind = this.wiringSourcePortKind;
if (sourceNode.type === "source" && portKind === "outflow") {
const d = sourceNode.data as SourceNodeData;
const color = SPENDING_COLORS[d.targetAllocations.length % SPENDING_COLORS.length] || "#10b981";
d.targetAllocations.push({ targetId: targetNodeId, percentage: 0, color });
this.normalizeAllocations(d.targetAllocations);
} else if (sourceNode.type === "funnel" && portKind === "overflow") {
const d = sourceNode.data as FunnelNodeData;
const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
this.normalizeAllocations(d.overflowAllocations);
} else if (sourceNode.type === "funnel" && portKind === "spending") {
const d = sourceNode.data as FunnelNodeData;
const color = SPENDING_COLORS[d.spendingAllocations.length % SPENDING_COLORS.length] || "#8b5cf6";
d.spendingAllocations.push({ targetId: targetNodeId, percentage: 0, color });
this.normalizeAllocations(d.spendingAllocations);
} else if (sourceNode.type === "outcome" && portKind === "overflow") {
const d = sourceNode.data as OutcomeNodeData;
if (!d.overflowAllocations) d.overflowAllocations = [];
const color = OVERFLOW_COLORS[d.overflowAllocations.length % OVERFLOW_COLORS.length] || "#f59e0b";
d.overflowAllocations.push({ targetId: targetNodeId, percentage: 0, color });
this.normalizeAllocations(d.overflowAllocations);
}
this.cancelWiring();
this.drawCanvasContent();
this.openEditor(this.wiringSourceNodeId || sourceNode.id);
}
private normalizeAllocations(allocs: { targetId: string; percentage: number; color: string }[]) {
if (allocs.length === 0) return;
const equal = Math.floor(100 / allocs.length);
const remainder = 100 - equal * allocs.length;
allocs.forEach((a, i) => { a.percentage = equal + (i === 0 ? remainder : 0); });
}
private allocationExists(fromId: string, toId: string, portKind: PortKind): boolean {
const node = this.nodes.find((n) => n.id === fromId);
if (!node) return false;
if (node.type === "source" && portKind === "outflow") {
return (node.data as SourceNodeData).targetAllocations.some((a) => a.targetId === toId);
}
if (node.type === "funnel" && portKind === "overflow") {
return (node.data as FunnelNodeData).overflowAllocations.some((a) => a.targetId === toId);
}
if (node.type === "funnel" && portKind === "spending") {
return (node.data as FunnelNodeData).spendingAllocations.some((a) => a.targetId === toId);
}
if (node.type === "outcome" && portKind === "overflow") {
return ((node.data as OutcomeNodeData).overflowAllocations || []).some((a) => a.targetId === toId);
}
return false;
}
private updateWiringTempLine() {
const wireLayer = this.shadow.getElementById("wire-layer");
if (!wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
const sourceNode = this.nodes.find((n) => n.id === this.wiringSourceNodeId);
if (!sourceNode) return;
const { x: x1, y: y1 } = this.getPortPosition(sourceNode, this.wiringSourcePortKind);
const svg = this.shadow.getElementById("flow-canvas") as SVGSVGElement | null;
if (!svg) return;
const rect = svg.getBoundingClientRect();
const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
const cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
wireLayer.innerHTML = `<path class="wiring-temp-path" d="M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}"/>`;
}
// ─── Node position update (direct DOM, no re-render) ──
private updateNodePosition(n: FlowNode) {
@ -1063,7 +1320,11 @@ class FolkFundsApp extends HTMLElement {
if (allocType === "source") {
allocs = (node.data as SourceNodeData).targetAllocations;
} else if (allocType === "overflow") {
allocs = (node.data as FunnelNodeData).overflowAllocations;
if (node.type === "outcome") {
allocs = (node.data as OutcomeNodeData).overflowAllocations || [];
} else {
allocs = (node.data as FunnelNodeData).overflowAllocations;
}
} else {
allocs = (node.data as FunnelNodeData).spendingAllocations;
}
@ -1196,6 +1457,9 @@ class FolkFundsApp extends HTMLElement {
}
html += `</div>`;
}
if (d.overflowAllocations && d.overflowAllocations.length > 0) {
html += this.renderAllocEditor("Overflow Allocations", d.overflowAllocations);
}
return html;
}
@ -1258,7 +1522,7 @@ class FolkFundsApp extends HTMLElement {
overflowAllocations: [], spendingAllocations: [],
} as FunnelNodeData;
} else {
data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started" } as OutcomeNodeData;
data = { label: "New Outcome", description: "", fundingReceived: 0, fundingTarget: 10000, status: "not-started", overflowAllocations: [] } as OutcomeNodeData;
}
this.nodes.push({ id, type, position: { x: cx - 100, y: cy - 50 }, data });
@ -1281,6 +1545,10 @@ class FolkFundsApp extends HTMLElement {
d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
d.spendingAllocations = d.spendingAllocations.filter((a) => a.targetId !== nodeId);
}
if (n.type === "outcome") {
const d = n.data as OutcomeNodeData;
if (d.overflowAllocations) d.overflowAllocations = d.overflowAllocations.filter((a) => a.targetId !== nodeId);
}
}
if (this.selectedNodeId === nodeId) this.selectedNodeId = null;
this.drawCanvasContent();
@ -1490,6 +1758,7 @@ class FolkFundsApp extends HTMLElement {
private cleanupCanvas() {
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
this.isSimulating = false;
if (this.wiringActive) this.cancelWiring();
if (this._boundKeyDown) { document.removeEventListener("keydown", this._boundKeyDown); this._boundKeyDown = null; }
}

View File

@ -297,6 +297,35 @@
.funds-tx__amount--negative { color: #ef4444; }
.funds-tx__time { font-size: 11px; color: #64748b; white-space: nowrap; }
/* ── Port & wiring ──────────────────────────────────── */
.port-group { pointer-events: all; }
.port-hit { cursor: crosshair; }
.port-dot { transition: r 0.15s, filter 0.15s; }
.port-group:hover .port-dot { r: 7; filter: drop-shadow(0 0 4px currentColor); }
.port-group--wiring-source .port-dot { animation: port-glow 0.8s ease-in-out infinite; }
.port-group--wiring-target .port-dot { animation: port-breathe 1s ease-in-out infinite; }
.port-group--wiring-dimmed { opacity: 0.15; pointer-events: none; }
.wiring-temp-path {
fill: none; stroke: #94a3b8; stroke-width: 2; stroke-dasharray: 8 4;
stroke-linecap: round; animation: wire-dash 0.6s linear infinite;
}
.funds-canvas-svg.wiring { cursor: crosshair; }
@keyframes port-glow {
0%, 100% { filter: drop-shadow(0 0 4px currentColor); }
50% { filter: drop-shadow(0 0 10px currentColor); }
}
@keyframes port-breathe {
0%, 100% { opacity: 0.6; r: 5; }
50% { opacity: 1; r: 7; }
}
@keyframes wire-dash {
to { stroke-dashoffset: -12; }
}
/* ── Mobile responsive ──────────────────────────────── */
@media (max-width: 768px) {
.funds-diagram { overflow-x: auto; -webkit-overflow-scrolling: touch; }

View File

@ -150,29 +150,63 @@ export function simulateTick(
updatedFunnels.set(node.id, data);
}
// Process outcomes in Y-order (like funnels) so overflow can cascade
const outcomeNodes = nodes
.filter((n) => n.type === "outcome")
.sort((a, b) => a.position.y - b.position.y);
const outcomeOverflowIncoming = new Map<string, number>();
const updatedOutcomes = new Map<string, OutcomeNodeData>();
for (const node of outcomeNodes) {
const src = node.data as OutcomeNodeData;
const data: OutcomeNodeData = { ...src };
const incoming = (spendingIncoming.get(node.id) ?? 0)
+ (overflowIncoming.get(node.id) ?? 0)
+ (outcomeOverflowIncoming.get(node.id) ?? 0);
if (incoming > 0) {
let newReceived = data.fundingReceived + incoming;
// Overflow: if fully funded and has overflow allocations, distribute excess
const allocs = data.overflowAllocations;
if (allocs && allocs.length > 0 && data.fundingTarget > 0 && newReceived > data.fundingTarget) {
const excess = newReceived - data.fundingTarget;
for (const alloc of allocs) {
const share = excess * (alloc.percentage / 100);
outcomeOverflowIncoming.set(alloc.targetId, (outcomeOverflowIncoming.get(alloc.targetId) ?? 0) + share);
}
newReceived = data.fundingTarget;
}
// Cap at 105% if no overflow allocations
if (!allocs || allocs.length === 0) {
newReceived = Math.min(
data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity,
newReceived,
);
}
data.fundingReceived = newReceived;
if (data.fundingTarget > 0 && data.fundingReceived >= data.fundingTarget && data.status !== "blocked") {
data.status = "completed";
} else if (data.fundingReceived > 0 && data.status === "not-started") {
data.status = "in-progress";
}
}
updatedOutcomes.set(node.id, data);
}
return nodes.map((node) => {
if (node.type === "funnel" && updatedFunnels.has(node.id)) {
return { ...node, data: updatedFunnels.get(node.id)! };
}
if (node.type === "outcome") {
const data = node.data as OutcomeNodeData;
const incoming = spendingIncoming.get(node.id) ?? 0;
if (incoming <= 0) return node;
const newReceived = Math.min(
data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity,
data.fundingReceived + incoming,
);
let newStatus = data.status;
if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== "blocked") {
newStatus = "completed";
} else if (newReceived > 0 && newStatus === "not-started") {
newStatus = "in-progress";
}
return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus } };
if (node.type === "outcome" && updatedOutcomes.has(node.id)) {
return { ...node, data: updatedOutcomes.get(node.id)! };
}
return node;

View File

@ -69,6 +69,7 @@ export interface OutcomeNodeData {
fundingTarget: number;
status: "not-started" | "in-progress" | "completed" | "blocked";
phases?: OutcomePhase[];
overflowAllocations?: OverflowAllocation[];
source?: IntegrationSource;
[key: string]: unknown;
}
@ -91,3 +92,36 @@ export interface FlowNode {
position: { x: number; y: number };
data: FunnelNodeData | OutcomeNodeData | SourceNodeData;
}
// ─── Port definitions ─────────────────────────────────
export type PortDirection = "in" | "out";
export type PortKind = "outflow" | "inflow" | "spending" | "overflow";
export interface PortDefinition {
kind: PortKind;
dir: PortDirection;
/** X offset as fraction of node width (01) */
xFrac: number;
/** Y offset: 0 = top, 1 = bottom */
yFrac: number;
color: string;
/** Which port kinds this output can wire to */
connectsTo?: PortKind[];
}
/** Single source of truth for port positions, colors, and connectivity rules. */
export const PORT_DEFS: Record<FlowNode["type"], PortDefinition[]> = {
source: [
{ kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
],
funnel: [
{ 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.7, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] },
],
outcome: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },
{ kind: "overflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#f59e0b", connectsTo: ["inflow"] },
],
};