Merge branch 'dev'
This commit is contained in:
commit
a9ebba03cb
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (0–1) */
|
||||
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"] },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue