Files

812 lines
20 KiB
JavaScript

/**
* Trace logic adapted from quick-connections <https://github.com/niknah/quick-connections>
*/
import { buildNodeBBoxIndex, findSegmentIntersections } from "./NodeBBoxIndex.js"
class MapLinks {
constructor(canvas, config) {
this.canvas = canvas;
this.nodesByRight = [];
this.nodesById = [];
this.pathsByLinkId = new Map();
this.nodeBBoxIndex = null;
this.config = config;
this.maxDirectLineDistance = Number.MAX_SAFE_INTEGER;
}
findClippedNode(outputXY, inputXY) {
const intersections = findSegmentIntersections(this.nodeBBoxIndex, outputXY, inputXY);
if (intersections.length) {
return intersections[0];
}
return null;
}
testPath(path) {
const len1 = (path.length - 1);
for (let p = 0; p < len1; ++p) {
const clipped = this.findClippedNode(path[p], path[p + 1]);
if (clipped) {
return clipped;
}
}
return null;
}
mapFinalLink(outputXY, inputXY) {
const clipped = this.findClippedNode(outputXY, inputXY);
if (!clipped) {
const dist = Math.sqrt(((outputXY[0] - inputXY[0]) ** 2) + ((outputXY[1] - inputXY[1]) ** 2));
if (dist < this.maxDirectLineDistance) {
return { path: _path90Or45(outputXY, inputXY) };
}
}
const horzDistance = inputXY[0] - outputXY[0];
const vertDistance = inputXY[1] - outputXY[1];
const horzDistanceAbs = Math.abs(horzDistance);
const vertDistanceAbs = Math.abs(vertDistance);
if (horzDistanceAbs > vertDistanceAbs) {
const goingLeft = inputXY[0] < outputXY[0];
const pathStraight45 = [
[outputXY[0], outputXY[1]],
[inputXY[0] - (goingLeft ? -vertDistanceAbs : vertDistanceAbs), outputXY[1]],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(pathStraight45)) {
return { path: pathStraight45 };
}
const path45Straight = [
[outputXY[0], outputXY[1]],
[outputXY[0] + (goingLeft ? -vertDistanceAbs : vertDistanceAbs), inputXY[1]],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(path45Straight)) {
return { path: path45Straight };
}
} else {
const goingUp = inputXY[1] < outputXY[1];
const pathStraight45 = [
[outputXY[0], outputXY[1]],
[outputXY[0], inputXY[1] + (goingUp ? horzDistanceAbs : -horzDistanceAbs)],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(pathStraight45)) {
return { path: pathStraight45 };
}
const path45Straight = [
[outputXY[0], outputXY[1]],
[inputXY[0], outputXY[1] - (goingUp ? horzDistanceAbs : -horzDistanceAbs)],
[inputXY[0], inputXY[1]],
];
if (!this.testPath(path45Straight)) {
return { path: path45Straight };
}
}
const path90Straight = [
[outputXY[0], outputXY[1]],
[outputXY[0], inputXY[1]],
[inputXY[0], inputXY[1]],
];
const clippedVert = this.testPath(path90Straight);
if (!clippedVert) {
return { path: path90Straight };
}
const pathStraight90 = [
[outputXY[0], outputXY[1]],
[inputXY[0], outputXY[1]],
[inputXY[0], inputXY[1]],
];
const clippedHorz = this.testPath(pathStraight90);
if (!clippedHorz) {
return { path: pathStraight90 };
}
return {
clippedHorz,
clippedVert,
};
}
mapLink(outputXY, inputXY, isBlocked) {
const { clippedHorz, clippedVert, path } = this.mapFinalLink(outputXY, inputXY);
if (path) {
return path;
}
const horzDistance = inputXY[0] - outputXY[0];
const vertDistance = inputXY[1] - outputXY[1];
const horzDistanceAbs = Math.abs(horzDistance);
const vertDistanceAbs = Math.abs(vertDistance);
let blockedNodeId;
let pathAroundNode;
let lastPathLocation;
let linesArea;
if (horzDistanceAbs > vertDistanceAbs) {
blockedNodeId = clippedHorz.node.node.id;
linesArea = clippedHorz.node.linesArea;
const horzEdge = horzDistance <= 0
? (linesArea[2])
: (linesArea[0] - 1);
pathAroundNode = [
[outputXY[0], outputXY[1]],
[horzEdge, outputXY[1]],
];
if (horzDistance <= 0) {
linesArea[2] += this.config.lineSpace;
} else {
linesArea[0] -= this.config.lineSpace;
}
const vertDistanceViaBlockTop =
Math.abs(inputXY[1] - linesArea[1]) +
Math.abs(linesArea[1] - outputXY[1]);
const vertDistanceViaBlockBottom =
Math.abs(inputXY[1] - linesArea[3]) +
Math.abs(linesArea[3] - outputXY[1]);
lastPathLocation = [
horzEdge,
vertDistanceViaBlockTop <= vertDistanceViaBlockBottom ?
(linesArea[1])
: (linesArea[3]),
];
const unblockNotPossible1 = this.testPath([...pathAroundNode, lastPathLocation]);
if (unblockNotPossible1) {
lastPathLocation = [
horzEdge,
vertDistanceViaBlockTop > vertDistanceViaBlockBottom ?
(linesArea[1])
: (linesArea[3]),
];
}
if (lastPathLocation[1] < outputXY[1]) {
linesArea[1] -= this.config.lineSpace;
lastPathLocation[1] -= 1;
} else {
linesArea[3] += this.config.lineSpace;
lastPathLocation[1] += 1;
}
} else {
blockedNodeId = clippedVert.node.node.id;
linesArea = clippedVert.node.linesArea;
const vertEdge =
vertDistance <= 0
? (linesArea[3] + 1)
: (linesArea[1] - 1);
pathAroundNode = [
[outputXY[0], outputXY[1]],
[outputXY[0], vertEdge],
];
if (vertDistance <= 0) {
linesArea[3] += this.config.lineSpace;
} else {
linesArea[1] -= this.config.lineSpace;
}
const horzDistanceViaBlockLeft =
Math.abs(inputXY[0] - linesArea[0]) +
Math.abs(linesArea[0] - outputXY[0]);
const horzDistanceViaBlockRight =
Math.abs(inputXY[0] - linesArea[2]) +
Math.abs(linesArea[2] - outputXY[0]);
lastPathLocation = [
horzDistanceViaBlockLeft <= horzDistanceViaBlockRight ?
(linesArea[0] - 1)
: (linesArea[2]),
vertEdge,
];
const unblockNotPossible1 = this.testPath([...pathAroundNode, lastPathLocation]);
if (unblockNotPossible1) {
lastPathLocation = [
horzDistanceViaBlockLeft > horzDistanceViaBlockRight ?
(linesArea[0])
: (linesArea[2]),
vertEdge,
];
}
if (lastPathLocation[0] < outputXY[0]) {
linesArea[0] -= this.config.lineSpace;
} else {
linesArea[2] += this.config.lineSpace;
}
}
if (isBlocked[blockedNodeId] > 3) {
isBlocked.blocked = true;
return _path90Or45(outputXY, inputXY);
}
if (isBlocked[blockedNodeId])
++isBlocked[blockedNodeId];
else
isBlocked[blockedNodeId] = 1;
const nextPath = this.mapLink(
lastPathLocation,
inputXY,
isBlocked,
);
return [...pathAroundNode, lastPathLocation, ...nextPath.slice(1)];
}
expandSourceNodeLinesArea(sourceNodeInfo, path) {
if (path.length < 3) {
return false;
}
const linesArea = sourceNodeInfo.linesArea;
if (path[1][0] === path[2][0]) {
linesArea[2] += this.config.lineSpace;
}
return true;
}
expandTargetNodeLinesArea(targetNodeInfo, path) {
if (path.length < 2) {
return false;
}
const linesArea = targetNodeInfo.linesArea;
const path2Len = path.length - 2;
if (path[path2Len - 1][0] === path[path2Len][0]) {
linesArea[0] -= this.config.lineSpace;
}
return true;
}
getNodeOnPos(xy) {
for (let i = 0; i < this.nodesByRight.length; ++i) {
const nodeI = this.nodesByRight[i];
const { linesArea } = nodeI;
if (xy[0] >= linesArea[0]
&& xy[1] >= linesArea[1]
&& xy[0] < linesArea[2]
&& xy[1] < linesArea[3]
) {
return nodeI;
}
}
return null;
}
mapLinks(nodesByExecution) {
const graphLinks = this.canvas.graph.links;
if (!graphLinks) {
console.error('Missing graph.links', this.canvas.graph);
return;
}
this.nodesByRight = [];
this.nodesById = {};
this.pathsByLinkId.clear();
this.nodesByRight = nodesByExecution.map((node) => {
const area = _getNodeArea(node);
if (!area) {
return null;
}
const linesArea = Array.from(area);
linesArea[0] += this.config.nodeSpace[0];
linesArea[1] += this.config.nodeSpace[1];
linesArea[2] += this.config.nodeSpace[2];
linesArea[3] += this.config.nodeSpace[3];
const obj = {
node,
area,
linesArea,
};
this.nodesById[node.id] = obj;
return obj;
}).filter(Boolean);
this.nodeBBoxIndex = buildNodeBBoxIndex(this.nodesByRight);
const nodesByRightId = this.nodesByRight.reduce(
(a, x) => {
a[x.node.id] = x.node;
return a;
},
{},
);
for (const nodeI of this.nodesByRight) {
const { node } = nodeI;
const outputs = node.outputs;
if (!outputs) {
continue;
}
for (let slot = 0; slot < outputs.length; slot++) {
const output = outputs[slot];
const links = output.links;
if (!links) {
continue;
}
const outputXYConnection = _getSlotPosition(node, slot, false);
if (!outputXYConnection) {
continue;
}
const outputNodeInfo = this.nodesById[node.id];
let outputXY = Array.from(outputXYConnection);
for (const linkId of links) {
outputXY[0] = outputNodeInfo.linesArea[2];
const link = _getGraphLink(graphLinks, linkId);
if (!link) {
continue;
}
let targetNode = this.canvas.graph.getNodeById(link.target_id);
if (!targetNode) {
targetNode = nodesByRightId[link.target_id];
}
if (!targetNode) {
continue;
}
const inputXYConnection = _getSlotPosition(targetNode, link.target_slot, true);
if (!inputXYConnection) {
continue;
}
const inputXY = Array.from(inputXYConnection);
const nodeInfo = this.nodesById[targetNode.id];
if (!nodeInfo) {
continue;
}
inputXY[0] = nodeInfo.linesArea[0] - 1;
const inputBlockedByNode =
this.getNodeOnPos(inputXY);
const outputBlockedByNode =
this.getNodeOnPos(outputXY);
let path = null;
if (!inputBlockedByNode && !outputBlockedByNode) {
const isBlocked = {};
const pathFound = this.mapLink(outputXY, inputXY, isBlocked);
if (pathFound) {
while (pathFound.length >= 2) {
const lastPathPoint1 = pathFound[pathFound.length - 1];
const lastPathPoint2 = pathFound[pathFound.length - 2];
if (lastPathPoint1[0] === lastPathPoint2[0]
&& lastPathPoint1[1] === lastPathPoint2[1]
) {
pathFound.pop();
} else {
break;
}
}
}
if (!isBlocked.blocked && pathFound && pathFound.length > 2) {
path = [outputXYConnection, ...pathFound, inputXYConnection];
this.expandTargetNodeLinesArea(nodeInfo, path);
}
}
if (!path) {
path = [outputXYConnection, outputXY, inputXY, inputXYConnection];
}
this.expandSourceNodeLinesArea(nodeI, path);
this.pathsByLinkId.set(linkId, path);
this.pathsByLinkId.set(String(linkId), path);
outputXY = [
outputXY[0] + this.config.lineSpace,
outputXY[1],
];
}
}
}
}
}
class TraceRoutes {
constructor() {
this.mapLinks = null;
this.graph = null;
this.maxDirectLineDistance = Number.MAX_SAFE_INTEGER;
this.config = {
lineSpace: Math.floor((_getLiteGraph()?.NODE_SLOT_HEIGHT ?? 20) / 2),
nodeSpace: [-8, -4, 12, 4],
};
}
recalcMapLinks() {
const canvas = { graph: this.graph };
this.mapLinks = new MapLinks(canvas, this.config);
this.mapLinks.maxDirectLineDistance = this.maxDirectLineDistance;
const nodesByExecution = _getRoutingNodes(this.graph);
try {
this.mapLinks.mapLinks(nodesByExecution);
} catch (e) {
console.error('mapLinks error', e);
}
}
}
function _getLiteGraph() {
return globalThis.LiteGraph || globalThis.window?.LiteGraph;
}
function _getGraphLink(graphLinks, linkId) {
return graphLinks?.[linkId] ?? graphLinks?.[String(linkId)] ?? graphLinks?.get?.(linkId) ?? graphLinks?.get?.(String(linkId));
}
function _appendUniqueNode(nodes, seen, node) {
if (!node) {
return;
}
const id = node.id ?? `${node.type ?? "node"}:${node.pos?.[0]},${node.pos?.[1]}`;
if (seen.has(id)) {
return;
}
seen.add(id);
nodes.push(node);
}
function _getRoutingNodes(graph) {
const nodes = [];
const seen = new Set();
let executionNodes = [];
try {
executionNodes = graph?.computeExecutionOrder?.() || [];
} catch (e) {
executionNodes = [];
}
executionNodes.forEach((node) => _appendUniqueNode(nodes, seen, node));
(graph?._nodes || []).forEach((node) => _appendUniqueNode(nodes, seen, node));
return nodes;
}
function _getNodeArea(node) {
if (!node) {
return null;
}
try {
if (node.getBounding) {
const barea = new Float32Array(4);
node.getBounding(barea);
return [
barea[0],
barea[1],
barea[0] + barea[2],
barea[1] + barea[3],
];
}
} catch (e) {
}
if (node.pos && node.size) {
return [
node.pos[0],
node.pos[1],
node.pos[0] + node.size[0],
node.pos[1] + node.size[1],
];
}
return null;
}
function _getSlotPosition(node, slot, isInput) {
try {
if (node.getSlotPosition) {
return node.getSlotPosition(slot, isInput);
}
if (isInput && node.getInputPos) {
return node.getInputPos(slot);
}
if (!isInput && node.getOutputPos) {
return node.getOutputPos(slot);
}
} catch (e) {
}
if (!node.pos || !node.size) {
return null;
}
const slotHeight = _getLiteGraph()?.NODE_SLOT_HEIGHT ?? 20;
const y = node.pos[1] + ((slot ?? 0) + 0.5) * slotHeight;
return isInput ? [node.pos[0], y] : [node.pos[0] + node.size[0], y];
}
function _drawTracePath(path2d, route, cornerRadius) {
if (route.length <= 1) {
return;
}
let isPrevDotRound = false;
for (let p = 0; p < route.length; ++p) {
const pos = route[p];
if (p === 0) {
path2d.moveTo(pos[0], pos[1]);
}
const prevPos = pos;
const cornerPos = route[p + 1];
const nextPos = route[p + 2];
let drawn = false;
if (nextPos) {
const xDiffBefore = cornerPos[0] - prevPos[0];
const yDiffBefore = cornerPos[1] - prevPos[1];
const xDiffAfter = nextPos[0] - cornerPos[0];
const yDiffAfter = nextPos[1] - cornerPos[1];
const lenBefore = Math.sqrt((xDiffBefore ** 2) + (yDiffBefore ** 2));
const lenAfter = Math.sqrt((xDiffAfter ** 2) + (yDiffAfter ** 2));
const cross = (xDiffBefore * yDiffAfter) - (yDiffBefore * xDiffAfter);
if (lenBefore > 0.001 && lenAfter > 0.001 && Math.abs(cross) > 0.001) {
const radius = Math.min(cornerRadius, lenBefore * 0.5, lenAfter * 0.5);
const beforePos = [
cornerPos[0] - ((xDiffBefore / lenBefore) * radius),
cornerPos[1] - ((yDiffBefore / lenBefore) * radius),
];
const afterPos = [
cornerPos[0] + ((xDiffAfter / lenAfter) * radius),
cornerPos[1] + ((yDiffAfter / lenAfter) * radius),
];
if (isPrevDotRound
&& Math.abs(isPrevDotRound[0] - beforePos[0]) <= cornerRadius
&& Math.abs(isPrevDotRound[1] - beforePos[1]) <= cornerRadius) {
} else {
path2d.lineTo(beforePos[0], beforePos[1]);
path2d.quadraticCurveTo(cornerPos[0], cornerPos[1], afterPos[0], afterPos[1]);
}
isPrevDotRound = beforePos;
drawn = true;
}
}
if (p > 0 && !drawn) {
if (!isPrevDotRound) {
path2d.lineTo(pos[0], pos[1]);
}
isPrevDotRound = false;
}
}
}
function _centerOf(route, pos) {
let best = -1;
for (let i = 0; i < route.length - 1; i++) {
const length = ((route[i + 1][0] - route[i][0]) ** 2) + ((route[i + 1][1] - route[i][1]) ** 2);
if (length > best) {
best = length;
pos[0] = (route[i][0] + route[i + 1][0]) * 0.5;
pos[1] = (route[i][1] + route[i + 1][1]) * 0.5;
pos[2] = Math.atan2(route[i + 1][1] - route[i][1], route[i + 1][0] - route[i][0]);
}
}
}
function _drawFallback90Or45(path, start, end, pos) {
const route = _path90Or45([start.x, start.y], [end.x, end.y]);
_centerOf(route, pos);
_drawTracePath(path, route, _traceRoutes.config.lineSpace);
}
function _samePoint(a, b) {
return a && b && Math.abs(a[0] - b[0]) < 0.001 && Math.abs(a[1] - b[1]) < 0.001;
}
function _appendUniquePoint(route, point) {
if (!_samePoint(route[route.length - 1], point)) {
route.push(point);
}
}
function _path90Or45(from, to) {
if (_samePoint(from, to)) {
return [from];
}
const dx = to[0] - from[0];
const dy = to[1] - from[1];
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const axisSnapEpsilon = 8;
if (absDy <= axisSnapEpsilon) {
const mid = [to[0], from[1]];
return _samePoint(mid, to) ? [from, to] : [from, mid, to];
}
if (absDx <= axisSnapEpsilon) {
const mid = [from[0], to[1]];
return _samePoint(mid, to) ? [from, to] : [from, mid, to];
}
if (dx === 0 || dy === 0 || Math.abs(absDx - absDy) < 0.001) {
return [from, to];
}
let mid;
if (absDx > absDy) {
mid = [to[0] - (Math.sign(dx) * absDy), from[1]];
} else {
mid = [from[0], to[1] - (Math.sign(dy) * absDx)];
}
return [from, mid, to];
}
function _normalizeRoute90Or45(points) {
if (points.length <= 1) {
return points;
}
const route = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const segment = _path90Or45(points[i], points[i + 1]);
for (const point of segment.slice(1)) {
_appendUniquePoint(route, point);
}
}
return route;
}
function _routeWithLiveEndpoints(route, start, end) {
const points = [[start.x, start.y]];
for (let i = 1; i < route.length - 1; i++) {
const point = route[i];
points.push([point[0], point[1]]);
}
points.push([end.x, end.y]);
if (points.length > 2) {
points[1][1] = start.y;
points[points.length - 2][1] = end.y;
}
return points;
}
const _traceRoutes = new TraceRoutes();
let _lastGraph = null;
let _lastSignature = null;
let _checkedGraphThisTask = null;
let _resetCheckScheduled = false;
function _mixHash(hash, value) {
hash ^= value >>> 0;
return Math.imul(hash, 16777619) >>> 0;
}
function _mixString(hash, value) {
const text = String(value ?? "");
for (let i = 0; i < text.length; i++) {
hash = _mixHash(hash, text.charCodeAt(i));
}
return hash;
}
function _mixCoord(hash, value) {
const num = Number.isFinite(value) ? Math.round(value * 1000) : 0;
return _mixHash(hash, num);
}
function _iterateLinks(graphLinks, callback) {
if (!graphLinks) {
return;
}
if (typeof graphLinks.forEach === "function") {
graphLinks.forEach((link, id) => callback(id, link));
return;
}
const linkIds = Object.keys(graphLinks);
for (let i = 0; i < linkIds.length; i++) {
const id = linkIds[i];
callback(id, graphLinks[id]);
}
}
function _graphSignature(graph) {
if (!graph) {
return 0;
}
let hash = 2166136261;
const nodes = graph._nodes || [];
hash = _mixHash(hash, nodes.length);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
hash = _mixString(hash, node.id);
hash = _mixCoord(hash, node.pos?.[0]);
hash = _mixCoord(hash, node.pos?.[1]);
hash = _mixCoord(hash, node.size?.[0]);
hash = _mixCoord(hash, node.size?.[1]);
}
let linkCount = 0;
_iterateLinks(graph.links, (id, link) => {
linkCount++;
hash = _mixString(hash, id);
hash = _mixString(hash, link?.origin_id);
hash = _mixHash(hash, link?.origin_slot ?? 0);
hash = _mixString(hash, link?.target_id);
hash = _mixHash(hash, link?.target_slot ?? 0);
});
hash = _mixHash(hash, linkCount);
return hash;
}
function _markCheckedThisTask(graph) {
_checkedGraphThisTask = graph;
if (_resetCheckScheduled) {
return;
}
_resetCheckScheduled = true;
setTimeout(() => {
_checkedGraphThisTask = null;
_resetCheckScheduled = false;
}, 0);
}
function _ensureTraceRoutes(graph, isStale = false) {
if (!graph) {
return;
}
_traceRoutes.graph = graph;
if (!isStale && _checkedGraphThisTask === graph && _traceRoutes.mapLinks) {
return;
}
const signature = _graphSignature(graph);
_markCheckedThisTask(graph);
if (!isStale && _lastGraph === graph && _lastSignature === signature && _traceRoutes.mapLinks) {
return;
}
_lastGraph = graph;
_lastSignature = signature;
_traceRoutes.recalcMapLinks();
}
export const traceRenderer = {
_linkId: null,
prepare(linkId, graph, isStale) {
this._linkId = linkId;
if (isStale) {
_lastSignature = null;
_checkedGraphThisTask = null;
}
_ensureTraceRoutes(graph, isStale);
},
draw(path, start, end, slot_id, start_node, end_node, radius, offset, curvature, pos, is_dragging) {
if (is_dragging || !start_node || !end_node) {
_drawFallback90Or45(path, start, end, pos);
return;
}
const route = this._linkId != null
? _traceRoutes.mapLinks?.pathsByLinkId.get(this._linkId)
: null;
if (!route) {
_drawFallback90Or45(path, start, end, pos);
return;
}
const fullRoute = _normalizeRoute90Or45(_routeWithLiveEndpoints(route, start, end));
_centerOf(fullRoute, pos);
_drawTracePath(path, fullRoute, _traceRoutes.config.lineSpace);
},
};