Major features:
- Adaptive drag mode with 5 fixed modes (Normal, Freeze+Check, Freeze, Hide, Heavy Deferred)
- Heavy Deferred for lag-free dragging in large workflows (250+ nodes)
- Three drag phases: moving → held → released, with priority-based re-routing during pause
- Node hard-obstacle avoidance — links can never pass through nodes
Performance:
- Progressive routing with frame-budgeted progressive batches
- Fast graph signature for geometry-change detection
- OVG rebuild deferred during large drags
- Collision re-check limited to affected links only
Fixes:
- Hover no longer triggers full-workflow redraw
- Krea2 / Ultimate SD Upscale link disappearance during drag
- Stale sticky paths now validated before reuse
- Drag-endpoint dogleg for straight-line links
New modules: adaptive-policy, drag-policy, draw-policy, profiler, progressive, ui-policy
Tests: 39 automated tests covering routing, adaptive, drag, profiler
Floating bar: Adaptive 🧠 toggle, 5 fixed-mode buttons, debug row, Nodes 2.0 switch
393 lines
12 KiB
JavaScript
393 lines
12 KiB
JavaScript
// draw.js — link drawing, flow animation, and hover tracking for LinkRouter.
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
import { M } from "./state.js";
|
|
import { routeAll, nodeRect } from "./routing.js";
|
|
import { profiler } from "./profiler.js";
|
|
import { hoverDrawItems } from "./draw-policy.js";
|
|
|
|
// --- color helpers ---
|
|
|
|
function linkColor(canvas, link) {
|
|
return (
|
|
link.color ||
|
|
canvas.default_connection_color_byType?.[link.type] ||
|
|
LGraphCanvas.link_type_colors?.[link.type] ||
|
|
canvas.default_link_color ||
|
|
"#9A9"
|
|
);
|
|
}
|
|
|
|
const darkenCache = new Map();
|
|
function darken(color, f = 0.3) {
|
|
const key = color + "|" + f;
|
|
let v = darkenCache.get(key);
|
|
if (v) return v;
|
|
let r = 128, g = 128, b = 128;
|
|
if (/^#([0-9a-f]{3})$/i.test(color)) {
|
|
r = parseInt(color[1] + color[1], 16);
|
|
g = parseInt(color[2] + color[2], 16);
|
|
b = parseInt(color[3] + color[3], 16);
|
|
} else if (/^#([0-9a-f]{6})$/i.test(color)) {
|
|
r = parseInt(color.slice(1, 3), 16);
|
|
g = parseInt(color.slice(3, 5), 16);
|
|
b = parseInt(color.slice(5, 7), 16);
|
|
}
|
|
v = `rgb(${(r * (1 - f)) | 0},${(g * (1 - f)) | 0},${(b * (1 - f)) | 0})`;
|
|
darkenCache.set(key, v);
|
|
return v;
|
|
}
|
|
|
|
// --- path tracing ---
|
|
|
|
function tracePath(ctx, pts) {
|
|
// CanvasRenderingContext2D needs beginPath(); Path2D has no such method.
|
|
if (typeof ctx.beginPath === "function") ctx.beginPath();
|
|
ctx.moveTo(pts[0].x, pts[0].y);
|
|
if (M.S.cornerMode === "off" || M.S.cornerRadius <= 0) {
|
|
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
return;
|
|
}
|
|
let uniform = M.S.cornerRadius;
|
|
if (M.S.cornerMode === "per-line") {
|
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
const p = pts[i - 1], c = pts[i], n = pts[i + 1];
|
|
uniform = Math.min(
|
|
uniform,
|
|
Math.hypot(c.x - p.x, c.y - p.y) / 2,
|
|
Math.hypot(n.x - c.x, n.y - c.y) / 2,
|
|
);
|
|
}
|
|
}
|
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
const p = pts[i - 1], c = pts[i], n = pts[i + 1];
|
|
const r =
|
|
M.S.cornerMode === "per-line"
|
|
? uniform
|
|
: Math.min(
|
|
M.S.cornerRadius,
|
|
Math.hypot(c.x - p.x, c.y - p.y) / 2,
|
|
Math.hypot(n.x - c.x, n.y - c.y) / 2,
|
|
);
|
|
ctx.arcTo(c.x, c.y, n.x, n.y, r);
|
|
}
|
|
ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
|
|
}
|
|
|
|
function cachedCanvasPath(cached) {
|
|
if (typeof Path2D === "undefined") return null;
|
|
const key = M.S.cornerMode + "|" + M.S.cornerRadius;
|
|
if (cached._canvasPath && cached._canvasPathKey === key && cached._canvasPathPts === cached.pts)
|
|
return cached._canvasPath;
|
|
const path = new Path2D();
|
|
tracePath(path, cached.pts);
|
|
cached._canvasPath = path;
|
|
cached._canvasPathKey = key;
|
|
cached._canvasPathPts = cached.pts;
|
|
return path;
|
|
}
|
|
|
|
function strokeCachedPath(ctx, cached) {
|
|
const path = cachedCanvasPath(cached);
|
|
if (path) ctx.stroke(path);
|
|
else {
|
|
tracePath(ctx, cached.pts);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
let staticBatchCache = null;
|
|
function staticBatches(canvas, routed) {
|
|
const profileStarted = profiler.active ? performance.now() : 0;
|
|
if (typeof Path2D === "undefined" || typeof Path2D.prototype.addPath !== "function") return null;
|
|
const colors = [];
|
|
let colorSig = "";
|
|
for (const { entry } of routed) {
|
|
const color = linkColor(canvas, entry.link);
|
|
colors.push(color);
|
|
colorSig += entry.link.id + ":" + color + ";";
|
|
}
|
|
const pathKey = M.S.cornerMode + "|" + M.S.cornerRadius;
|
|
if (
|
|
staticBatchCache?.routed === routed &&
|
|
staticBatchCache.pathKey === pathKey &&
|
|
staticBatchCache.colorSig === colorSig
|
|
) {
|
|
if (profiler.active)
|
|
profiler.recordBatch({ hit: true, durationMs: Math.round((performance.now() - profileStarted) * 1000) / 1000 });
|
|
return staticBatchCache;
|
|
}
|
|
|
|
const all = new Path2D();
|
|
const byColor = new Map();
|
|
for (let i = 0; i < routed.length; i++) {
|
|
const { entry, cached } = routed[i];
|
|
const path = cachedCanvasPath(cached);
|
|
if (!path) return null;
|
|
all.addPath(path);
|
|
const color = colors[i];
|
|
let batch = byColor.get(color);
|
|
if (!batch) {
|
|
batch = new Path2D();
|
|
byColor.set(color, batch);
|
|
}
|
|
batch.addPath(path);
|
|
const pts = cached.pts;
|
|
const mid = pts[Math.floor(pts.length / 2)];
|
|
entry.link._pos && ((entry.link._pos[0] = mid.x), (entry.link._pos[1] = mid.y));
|
|
}
|
|
staticBatchCache = { routed, pathKey, colorSig, all, byColor };
|
|
if (profiler.active)
|
|
profiler.recordBatch({ hit: false, durationMs: Math.round((performance.now() - profileStarted) * 1000) / 1000 });
|
|
return staticBatchCache;
|
|
}
|
|
|
|
// --- marker shapes ---
|
|
|
|
function markerPath(ctx, style, x, y, ang, s) {
|
|
ctx.beginPath();
|
|
if (style === "dots") {
|
|
ctx.arc(x, y, s / 2 + 0.5, 0, Math.PI * 2);
|
|
return;
|
|
}
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.rotate(ang);
|
|
if (style === "pill") {
|
|
const L = s * 2, R = s / 2;
|
|
ctx.moveTo(-L / 2 + R, -R);
|
|
ctx.lineTo(L / 2 - R, -R);
|
|
ctx.arc(L / 2 - R, 0, R, -Math.PI / 2, Math.PI / 2);
|
|
ctx.lineTo(-L / 2 + R, R);
|
|
ctx.arc(-L / 2 + R, 0, R, Math.PI / 2, (3 * Math.PI) / 2);
|
|
} else if (style === "arrow") {
|
|
ctx.moveTo(s, 0);
|
|
ctx.lineTo(-s * 0.7, -s * 0.7);
|
|
ctx.lineTo(-s * 0.3, 0);
|
|
ctx.lineTo(-s * 0.7, s * 0.7);
|
|
ctx.closePath();
|
|
} else if (style === "oval") {
|
|
ctx.ellipse(0, 0, s, s * 0.55, 0, 0, Math.PI * 2);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// --- flow animation ---
|
|
|
|
function drawFlow(ctx, cached, t, baseColor, alpha, staticArrows = false) {
|
|
const color =
|
|
M.S.animColorUse && M.S.animColor ? M.S.animColor : darken(baseColor, 0.3);
|
|
const gap = Math.max(10, +M.S.animGap || 36);
|
|
const speed = +M.S.animSpeed || 60;
|
|
const size = +M.S.animSize || 5;
|
|
const style = staticArrows ? "arrow" : M.S.animStyle;
|
|
|
|
if (style === "dash") {
|
|
ctx.save();
|
|
ctx.globalAlpha = Math.min(1, alpha + 0.2);
|
|
if (M.S.animOutline) {
|
|
ctx.strokeStyle = "rgba(0,0,0,0.5)";
|
|
ctx.lineWidth = size * 0.6 + (+M.S.animOutlineWidth || 2);
|
|
ctx.setLineDash([size * 2, gap]);
|
|
ctx.lineDashOffset = -((t * speed) % (size * 2 + gap));
|
|
strokeCachedPath(ctx, cached);
|
|
}
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = Math.max(1, size * 0.6);
|
|
ctx.setLineDash([size * 2, gap]);
|
|
ctx.lineDashOffset = -((t * speed) % (size * 2 + gap));
|
|
strokeCachedPath(ctx, cached);
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const { segs, total } = cached;
|
|
if (!segs || total < 4) return;
|
|
const offset = (t * speed) % gap;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = Math.min(1, alpha + 0.25);
|
|
ctx.fillStyle = color;
|
|
ctx.strokeStyle = "rgba(0,0,0,0.5)";
|
|
ctx.lineWidth = +M.S.animOutlineWidth || 2;
|
|
let si = 0;
|
|
for (let d = offset; d < total; d += gap) {
|
|
while (si < segs.length - 1 && d > segs[si].start + segs[si].len) si++;
|
|
const s = segs[si];
|
|
const f = s.len > 0 ? (d - s.start) / s.len : 0;
|
|
const x = s.a.x + s.dx * f;
|
|
const y = s.a.y + s.dy * f;
|
|
markerPath(ctx, style, x, y, s.ang, size);
|
|
if (M.S.animOutline) ctx.stroke();
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// --- animation loop ---
|
|
|
|
function ensureAnimLoop(need) {
|
|
if (need && !M.animActive) {
|
|
M.animActive = true;
|
|
const tick = (now) => {
|
|
if (!M.animActive) return;
|
|
const interval = 1000 / M.currentFPS();
|
|
if (now - M.lastFrame >= interval) {
|
|
M.lastFrame = now;
|
|
app.canvas?.setDirty(true, true);
|
|
}
|
|
M.rafId = requestAnimationFrame(tick);
|
|
};
|
|
M.rafId = requestAnimationFrame(tick);
|
|
} else if (!need && M.animActive) {
|
|
M.animActive = false;
|
|
cancelAnimationFrame(M.rafId);
|
|
}
|
|
}
|
|
|
|
// --- hover detection ---
|
|
|
|
function hoverNodeId(canvas) {
|
|
if (canvas.node_over) return canvas.node_over.id;
|
|
if (!M.mouseClient || !canvas?.ds) return null;
|
|
const el = canvas.canvas;
|
|
if (!el) return null;
|
|
const rect = el.getBoundingClientRect();
|
|
if (
|
|
M.mouseClient.x < rect.left ||
|
|
M.mouseClient.x > rect.right ||
|
|
M.mouseClient.y < rect.top ||
|
|
M.mouseClient.y > rect.bottom
|
|
)
|
|
return null;
|
|
const scale = canvas.ds.scale || 1;
|
|
const gx = (M.mouseClient.x - rect.left) / scale - canvas.ds.offset[0];
|
|
const gy = (M.mouseClient.y - rect.top) / scale - canvas.ds.offset[1];
|
|
const nodes = canvas.graph?._nodes || [];
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const r = nodeRect(nodes[i]);
|
|
if (gx >= r.x && gx <= r.x + r.w && gy >= r.y && gy <= r.y + r.h)
|
|
return nodes[i].id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- main draw hook ---
|
|
|
|
export function drawAll(canvas, ctx) {
|
|
const graph = canvas.graph;
|
|
if (!graph) return;
|
|
// Guard: router not ready yet (settings may still be loading)
|
|
if (!M.router) return false;
|
|
const profileFrame = profiler.beginFrame(canvas);
|
|
let routed;
|
|
try {
|
|
routed = routeAll(graph);
|
|
} catch (err) {
|
|
console.warn("[LinkRouter] routing failed, falling back", err?.message || err);
|
|
M.S.enabled = false;
|
|
profiler.endFrame(profileFrame, { fallback: true });
|
|
return false;
|
|
}
|
|
if (routed === null) {
|
|
profiler.endFrame(profileFrame, { fallback: true });
|
|
return false;
|
|
}
|
|
|
|
const selIds = new Set(Object.keys(canvas.selected_nodes || {}).map(Number));
|
|
const hovId = M.S.hoverAnim ? hoverNodeId(canvas) : null;
|
|
const hoverId = hovId !== null && !selIds.has(hovId) ? hovId : null;
|
|
const hasSel = M.S.selectHighlight && selIds.size > 0;
|
|
const isDragging = M._nodeDragActive;
|
|
const related = (link) => selIds.has(link.origin_id) || selIds.has(link.target_id);
|
|
const hovered = (link) =>
|
|
hoverId !== null && (link.origin_id === hoverId || link.target_id === hoverId);
|
|
const animOK = M.animEnabledNow();
|
|
|
|
let animOn = false;
|
|
let strokeCalls = 0;
|
|
const t = performance.now() / 1000;
|
|
|
|
ctx.save();
|
|
ctx.lineJoin = "round";
|
|
ctx.lineCap = "round";
|
|
// Hover must not switch the entire workflow from color batches to per-link
|
|
// drawing. Keep the unchanged batch as the base and overlay only links
|
|
// attached to the hovered node so they genuinely render on top.
|
|
const batches = !hasSel ? staticBatches(canvas, routed) : null;
|
|
const drawItems = hoverDrawItems(routed, hoverId, !!batches);
|
|
if (batches) {
|
|
const w = +M.S.lineWidth || 3;
|
|
if (M.S.outline) {
|
|
ctx.strokeStyle = `rgba(0,0,0,${+M.S.outlineAlpha || 0.5})`;
|
|
ctx.lineWidth = w + (+M.S.outlineWidth || 2);
|
|
ctx.stroke(batches.all);
|
|
strokeCalls++;
|
|
}
|
|
ctx.lineWidth = w;
|
|
for (const [color, path] of batches.byColor) {
|
|
ctx.strokeStyle = color;
|
|
ctx.stroke(path);
|
|
strokeCalls++;
|
|
}
|
|
}
|
|
for (const { entry, cached } of drawItems) {
|
|
const pts = cached.pts;
|
|
const link = entry.link;
|
|
const isSel = hasSel && related(link);
|
|
const isHov = M.S.hoverAnim && hovered(link);
|
|
let alpha = 1;
|
|
if (hasSel && !isSel) {
|
|
// During drag, use a separate (less aggressive) dim value
|
|
// so you can still see the canvas layout while dragging.
|
|
alpha = isDragging && M.S.dragDimAlpha > 0
|
|
? +M.S.dragDimAlpha
|
|
: +M.S.dimAlpha;
|
|
}
|
|
if (alpha <= 0.01) continue;
|
|
|
|
ctx.globalAlpha = alpha;
|
|
const w = (+M.S.lineWidth || 3) * (isSel ? +M.S.selectBoost || 1.35 : 1);
|
|
const color = linkColor(canvas, link);
|
|
|
|
if (M.S.outline) {
|
|
ctx.strokeStyle = `rgba(0,0,0,${+M.S.outlineAlpha || 0.5})`;
|
|
ctx.lineWidth = w + (+M.S.outlineWidth || 2);
|
|
strokeCachedPath(ctx, cached);
|
|
strokeCalls++;
|
|
}
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = w;
|
|
strokeCachedPath(ctx, cached);
|
|
strokeCalls++;
|
|
|
|
if ((isSel && M.S.selectAnim) || isHov) {
|
|
if (M.S.flowMode === "animated" && animOK) {
|
|
animOn = true;
|
|
drawFlow(ctx, cached, t, color, alpha, false);
|
|
} else if (M.S.flowMode === "static" || (M.S.flowMode === "animated" && !animOK)) {
|
|
drawFlow(ctx, cached, 0, color, alpha, true);
|
|
}
|
|
}
|
|
|
|
const mid = pts[Math.floor(pts.length / 2)];
|
|
link._pos && ((link._pos[0] = mid.x), (link._pos[1] = mid.y));
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
if (M.barState.debug) {
|
|
const info = M.router.debugInfo();
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeStyle = "rgba(255,80,80,0.5)";
|
|
for (const r of info.rects) ctx.strokeRect(r.x, r.y, r.x2 - r.x, r.y2 - r.y);
|
|
ctx.fillStyle = "rgba(80,180,255,0.8)";
|
|
for (const { cached } of routed)
|
|
for (const p of cached.pts) ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
|
}
|
|
ctx.restore();
|
|
|
|
ensureAnimLoop(animOn);
|
|
profiler.endFrame(profileFrame, { links: routed.length, strokeCalls, batched: !!batches });
|
|
return true;
|
|
}
|