feat(rflows): close source→funnel flow gap, interactive sizing + drag handles
Source nodes now drive funnel inflow rates via computeInflowRates() which sums source allocations before each simulation tick. Source width scales with flowRate, funnel height scales linearly with capacity, and valve/ capacity drag handles are always visible on hover (no inline-edit needed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b2862afd7
commit
eeae7d2aa1
|
|
@ -274,6 +274,13 @@
|
||||||
.flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; }
|
.flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; }
|
||||||
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
|
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
|
||||||
|
|
||||||
|
/* Funnel drag handles — hidden by default, visible on hover */
|
||||||
|
.funnel-valve-handle, .funnel-height-handle { opacity: 0; transition: opacity 0.2s; }
|
||||||
|
.flow-node:hover .funnel-valve-handle,
|
||||||
|
.flow-node:hover .funnel-height-handle { opacity: 0.8; }
|
||||||
|
.funnel-valve-handle:hover { opacity: 1 !important; }
|
||||||
|
.funnel-height-handle:hover { opacity: 1 !important; }
|
||||||
|
|
||||||
/* HTML card nodes (foreignObject) */
|
/* HTML card nodes (foreignObject) */
|
||||||
.node-card {
|
.node-card {
|
||||||
background: white; border-radius: 12px; overflow: hidden;
|
background: white; border-radius: 12px; overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
||||||
import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
||||||
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||||
import { mapFlowToNodes } from "../lib/map-flow";
|
import { mapFlowToNodes } from "../lib/map-flow";
|
||||||
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
|
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
|
||||||
|
|
@ -215,7 +215,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const flow = doc.canvasFlows?.[this.currentFlowId];
|
const flow = doc.canvasFlows?.[this.currentFlowId];
|
||||||
if (flow && !this.saveTimer) {
|
if (flow && !this.saveTimer) {
|
||||||
// Only update if we're not in the middle of saving
|
// Only update if we're not in the middle of saving
|
||||||
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
|
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
|
||||||
this.drawCanvasContent();
|
this.drawCanvasContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -259,7 +259,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
if (!flow) return;
|
if (!flow) return;
|
||||||
this.currentFlowId = flow.id;
|
this.currentFlowId = flow.id;
|
||||||
this.flowName = flow.name;
|
this.flowName = flow.name;
|
||||||
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
|
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
|
||||||
this.localFirstClient?.setActiveFlow(flowId);
|
this.localFirstClient?.setActiveFlow(flowId);
|
||||||
this.restoreViewport(flowId);
|
this.restoreViewport(flowId);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
|
@ -1042,14 +1042,17 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
||||||
if (n.type === "source") {
|
if (n.type === "source") {
|
||||||
return { w: 220, h: 120 };
|
const d = n.data as SourceNodeData;
|
||||||
|
const baseW = 180;
|
||||||
|
const w = Math.round(baseW + Math.min(120, Math.sqrt(d.flowRate / 100) * 20));
|
||||||
|
return { w, h: 120 };
|
||||||
}
|
}
|
||||||
if (n.type === "funnel") {
|
if (n.type === "funnel") {
|
||||||
const d = n.data as FunnelNodeData;
|
const d = n.data as FunnelNodeData;
|
||||||
const baseW = 280, baseH = 250;
|
const baseW = 280;
|
||||||
const hRef = d.maxCapacity || 9000;
|
const cap = d.maxCapacity || 9000;
|
||||||
const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35;
|
const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
|
||||||
return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) };
|
return { w: baseW, h: Math.max(200, h) };
|
||||||
}
|
}
|
||||||
return { w: 220, h: 180 }; // outcome card
|
return { w: 220, h: 180 }; // outcome card
|
||||||
}
|
}
|
||||||
|
|
@ -1083,6 +1086,75 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
this.updateCanvasTransform();
|
this.updateCanvasTransform();
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Delegated funnel valve + height drag handles
|
||||||
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
|
const target = e.target as Element;
|
||||||
|
const valveG = target.closest(".funnel-valve-handle") as SVGGElement | null;
|
||||||
|
const heightG = target.closest(".funnel-height-handle") as SVGGElement | null;
|
||||||
|
const handleG = valveG || heightG;
|
||||||
|
if (!handleG) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const nodeId = handleG.getAttribute("data-node-id");
|
||||||
|
const node = this.nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node || node.type !== "funnel") return;
|
||||||
|
const fd = node.data as FunnelNodeData;
|
||||||
|
const s = this.getNodeSize(node);
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
|
||||||
|
if (valveG) {
|
||||||
|
const startOutflow = fd.desiredOutflow || 0;
|
||||||
|
handleG.setPointerCapture(e.pointerId);
|
||||||
|
const label = handleG.querySelector("text");
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const deltaX = (ev.clientX - startX) / this.canvasZoom;
|
||||||
|
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
||||||
|
newOutflow = Math.max(0, Math.min(10000, newOutflow));
|
||||||
|
fd.desiredOutflow = newOutflow;
|
||||||
|
fd.minThreshold = newOutflow;
|
||||||
|
fd.maxThreshold = newOutflow * 6;
|
||||||
|
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
|
||||||
|
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
|
||||||
|
}
|
||||||
|
if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handleG.removeEventListener("pointermove", onMove as EventListener);
|
||||||
|
handleG.removeEventListener("pointerup", onUp);
|
||||||
|
handleG.removeEventListener("lostpointercapture", onUp);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
this.redrawEdges();
|
||||||
|
this.scheduleSave();
|
||||||
|
};
|
||||||
|
handleG.addEventListener("pointermove", onMove as EventListener);
|
||||||
|
handleG.addEventListener("pointerup", onUp);
|
||||||
|
handleG.addEventListener("lostpointercapture", onUp);
|
||||||
|
} else {
|
||||||
|
const startCapacity = fd.maxCapacity || 9000;
|
||||||
|
handleG.setPointerCapture(e.pointerId);
|
||||||
|
const label = handleG.querySelector("text");
|
||||||
|
const onMove = (ev: PointerEvent) => {
|
||||||
|
const deltaY = (ev.clientY - startY) / this.canvasZoom;
|
||||||
|
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
||||||
|
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
||||||
|
fd.maxCapacity = newCapacity;
|
||||||
|
if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
handleG.removeEventListener("pointermove", onMove as EventListener);
|
||||||
|
handleG.removeEventListener("pointerup", onUp);
|
||||||
|
handleG.removeEventListener("lostpointercapture", onUp);
|
||||||
|
this.drawCanvasContent();
|
||||||
|
this.redrawEdges();
|
||||||
|
this.scheduleSave();
|
||||||
|
};
|
||||||
|
handleG.addEventListener("pointermove", onMove as EventListener);
|
||||||
|
handleG.addEventListener("pointerup", onUp);
|
||||||
|
handleG.addEventListener("lostpointercapture", onUp);
|
||||||
|
}
|
||||||
|
}, { capture: true });
|
||||||
|
|
||||||
// Panning — pointerdown on SVG background
|
// Panning — pointerdown on SVG background
|
||||||
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||||
const target = e.target as Element;
|
const target = e.target as Element;
|
||||||
|
|
@ -1608,6 +1680,10 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
|
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flow-width bar: visual river-width proportional to flowRate
|
||||||
|
const flowBarMaxW = w - 24;
|
||||||
|
const flowBarW = Math.round(12 + Math.min(flowBarMaxW - 12, Math.sqrt(d.flowRate / 100) * (flowBarMaxW / 6)));
|
||||||
|
|
||||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
|
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
|
||||||
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
||||||
|
|
@ -1624,6 +1700,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
${allocBarHtml}
|
${allocBarHtml}
|
||||||
</div>
|
</div>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
|
<rect x="${(w - flowBarW) / 2}" y="${h - 6}" width="${flowBarW}" height="4" rx="2" style="fill:#10b981;opacity:0.5"/>
|
||||||
${this.renderPortsSvg(n)}
|
${this.renderPortsSvg(n)}
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
@ -1647,7 +1724,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const taperStart = 0.80; // body tapers at 80% down
|
const taperStart = 0.80; // body tapers at 80% down
|
||||||
// Drain width proportional to outflow: wider drain = more outflow
|
// Drain width proportional to outflow: wider drain = more outflow
|
||||||
const outflow = d.desiredOutflow || 0;
|
const outflow = d.desiredOutflow || 0;
|
||||||
const outflowRatio = Math.min(1, outflow / 3000);
|
const outflowRatio = Math.min(1, outflow / 10000);
|
||||||
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
|
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
|
||||||
const insetPx = Math.round(w * taperInset);
|
const insetPx = Math.round(w * taperInset);
|
||||||
const taperY = Math.round(h * taperStart);
|
const taperY = Math.round(h * taperStart);
|
||||||
|
|
@ -1761,6 +1838,18 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
|
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
|
||||||
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
|
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
|
||||||
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
|
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
|
||||||
|
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
|
||||||
|
<rect x="${insetPx - 8}" y="${h - 16}" width="${w - insetPx * 2 + 16}" height="18" rx="5"
|
||||||
|
style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
|
||||||
|
<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
|
||||||
|
◁ ${this.formatDollar(outflow)}/mo ▷
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
<g class="funnel-height-handle" data-handle="height" data-node-id="${n.id}">
|
||||||
|
<rect x="${w / 2 - 28}" y="${h + 4}" width="56" height="12" rx="5"
|
||||||
|
style="fill:var(--rs-border-strong);cursor:ns-resize;stroke:var(--rs-text-muted);stroke-width:1"/>
|
||||||
|
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none">⇕ capacity</text>
|
||||||
|
</g>
|
||||||
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
|
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
|
||||||
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
||||||
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
|
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
|
||||||
|
|
@ -2579,7 +2668,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
if (node.type === "funnel") {
|
if (node.type === "funnel") {
|
||||||
const d = node.data as FunnelNodeData;
|
const d = node.data as FunnelNodeData;
|
||||||
const outflow = d.desiredOutflow || 0;
|
const outflow = d.desiredOutflow || 0;
|
||||||
const outflowRatio = Math.min(1, outflow / 3000);
|
const outflowRatio = Math.min(1, outflow / 10000);
|
||||||
const valveInset = 0.30 - outflowRatio * 0.18;
|
const valveInset = 0.30 - outflowRatio * 0.18;
|
||||||
const valveInsetPx = Math.round(s.w * valveInset);
|
const valveInsetPx = Math.round(s.w * valveInset);
|
||||||
const drainWidth = s.w - 2 * valveInsetPx;
|
const drainWidth = s.w - 2 * valveInsetPx;
|
||||||
|
|
@ -2828,8 +2917,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const onMove = (ev: Event) => {
|
const onMove = (ev: Event) => {
|
||||||
const me = ev as PointerEvent;
|
const me = ev as PointerEvent;
|
||||||
const deltaX = (me.clientX - startX) / this.canvasZoom;
|
const deltaX = (me.clientX - startX) / this.canvasZoom;
|
||||||
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 3000) / 50) * 50;
|
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
||||||
newOutflow = Math.max(0, Math.min(3000, newOutflow));
|
newOutflow = Math.max(0, Math.min(10000, newOutflow));
|
||||||
fd.desiredOutflow = newOutflow;
|
fd.desiredOutflow = newOutflow;
|
||||||
fd.minThreshold = newOutflow;
|
fd.minThreshold = newOutflow;
|
||||||
fd.maxThreshold = newOutflow * 6;
|
fd.maxThreshold = newOutflow * 6;
|
||||||
|
|
@ -2874,7 +2963,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const deltaY = (me.clientY - startY) / this.canvasZoom;
|
const deltaY = (me.clientY - startY) / this.canvasZoom;
|
||||||
// Down = more capacity, up = less
|
// Down = more capacity, up = less
|
||||||
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
||||||
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(50000, newCapacity));
|
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
||||||
fd.maxCapacity = newCapacity;
|
fd.maxCapacity = newCapacity;
|
||||||
// Update label
|
// Update label
|
||||||
const label = overlay.querySelector(".height-drag-label");
|
const label = overlay.querySelector(".height-drag-label");
|
||||||
|
|
@ -3855,6 +3944,7 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
if (this.simInterval) clearInterval(this.simInterval);
|
if (this.simInterval) clearInterval(this.simInterval);
|
||||||
this.simInterval = setInterval(() => {
|
this.simInterval = setInterval(() => {
|
||||||
this.simTickCount++;
|
this.simTickCount++;
|
||||||
|
this.nodes = computeInflowRates(this.nodes);
|
||||||
this.nodes = simulateTick(this.nodes);
|
this.nodes = simulateTick(this.nodes);
|
||||||
this.accumulateNodeAnalytics();
|
this.accumulateNodeAnalytics();
|
||||||
this.updateCanvasLive();
|
this.updateCanvasLive();
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Ported from rflows-online/lib/simulation.ts.
|
* Ported from rflows-online/lib/simulation.ts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types";
|
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types";
|
||||||
import { deriveThresholds } from "./types";
|
import { deriveThresholds } from "./types";
|
||||||
|
|
||||||
export interface SimulationConfig {
|
export interface SimulationConfig {
|
||||||
|
|
@ -85,6 +85,31 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
|
||||||
return count > 0 ? sum / count : 0;
|
return count > 0 ? sum / count : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync source→funnel allocations into each funnel's inflowRate.
|
||||||
|
* Funnels with no source wired keep their manual inflowRate (backward compat).
|
||||||
|
*/
|
||||||
|
export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
|
||||||
|
const computed = new Map<string, number>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.type === "source") {
|
||||||
|
const d = n.data as SourceNodeData;
|
||||||
|
for (const alloc of d.targetAllocations) {
|
||||||
|
computed.set(
|
||||||
|
alloc.targetId,
|
||||||
|
(computed.get(alloc.targetId) ?? 0) + d.flowRate * (alloc.percentage / 100),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes.map((n) => {
|
||||||
|
if (n.type === "funnel" && computed.has(n.id)) {
|
||||||
|
return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function simulateTick(
|
export function simulateTick(
|
||||||
nodes: FlowNode[],
|
nodes: FlowNode[],
|
||||||
config: SimulationConfig = DEFAULT_CONFIG,
|
config: SimulationConfig = DEFAULT_CONFIG,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue