Files
90-RED 734ce4173f v1.4.3 — faster progressive reveal + fix links vanishing after drag
Worker prewarm, viewport-first stable batches, and adaptive reveal pacing
(default on, clamp 6–14ms). On drag settle force OVG rebuild and never drop
the last legal path when route/stretch fail.
2026-07-30 10:44:53 +08:00

754 lines
27 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, animDensityScale } 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;
// Memoized 32-bit hash per color string — color values repeat heavily
// across links, so per-link hashing is just one Map lookup + one imul.
const colorCodeCache = new Map();
function colorCode(color) {
let v = colorCodeCache.get(color);
if (v === undefined) {
v = 1;
for (let c = 0; c < color.length; c++)
v = Math.imul(v ^ color.charCodeAt(c), 16777619);
colorCodeCache.set(color, v);
}
return v;
}
// --- viewport culling ---
//
// linkBounds/cullRectFor are exact: a stroked path can only reach its point
// bounds plus half the stroke width, so skipping out-of-rect links can never
// remove a visible pixel. The rect is snapped OUTWARD to 128px so small pans
// keep hitting the batch cache (the cached batch is a superset of the view).
function linkBounds(cached) {
if (cached._pb && cached._pb.pts === cached.pts) return cached._pb;
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of cached.pts) {
if (p.x < x0) x0 = p.x;
if (p.y < y0) y0 = p.y;
if (p.x > x1) x1 = p.x;
if (p.y > y1) y1 = p.y;
}
const b = { pts: cached.pts, x0, y0, x1, y1 };
cached._pb = b;
return b;
}
const CULL_SNAP = 128;
function cullRectFor(canvas) {
// canvas.visible_area is litegraph's Rectangle [x, y, w, h] in graph
// coordinates (verified in frontend 1.45.21: LGraphCanvas.visible_area ===
// DragAndScale.visible_area, resized via resizeBottomRight).
const va = canvas.visible_area;
if (!va || va.length < 4 || !(va[2] > 0) || !(va[3] > 0)) return null;
const m = (+M.S.lineWidth || 3) + (+M.S.outlineWidth || 2) + 2;
return {
x0: Math.floor((va[0] - m) / CULL_SNAP) * CULL_SNAP,
y0: Math.floor((va[1] - m) / CULL_SNAP) * CULL_SNAP,
x1: Math.ceil((va[0] + va[2] + m) / CULL_SNAP) * CULL_SNAP,
y1: Math.ceil((va[1] + va[3] + m) / CULL_SNAP) * CULL_SNAP,
};
}
function boundsOutside(b, r) {
return b.x1 < r.x0 || b.x0 > r.x1 || b.y1 < r.y0 || b.y0 > r.y1;
}
function staticBatches(canvas, routed, cull) {
const profileStarted = profiler.active ? performance.now() : 0;
if (typeof Path2D === "undefined" || typeof Path2D.prototype.addPath !== "function") return null;
const colors = [];
let colorHash = 0x811c9dc5;
for (const { entry } of routed) {
const color = linkColor(canvas, entry.link);
colors.push(color);
colorHash = Math.imul(
colorHash ^ ((entry.link.id | 0) ^ colorCode(color)),
16777619,
);
}
const pathKey = M.S.cornerMode + "|" + M.S.cornerRadius;
const cullKey = cull ? cull.x0 + "," + cull.y0 + "," + cull.x1 + "," + cull.y1 : "";
if (
staticBatchCache?.routed === routed &&
staticBatchCache.pathKey === pathKey &&
staticBatchCache.colorHash === colorHash &&
staticBatchCache.cullKey === cullKey
) {
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 pts = cached.pts;
// Keep every link's center fresh for litegraph's tooltip hit-testing,
// even for links culled out of the current viewport.
const mid = pts[Math.floor(pts.length / 2)];
entry.link._pos && ((entry.link._pos[0] = mid.x), (entry.link._pos[1] = mid.y));
if (cull && boundsOutside(linkBounds(cached), cull)) continue;
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);
}
staticBatchCache = { routed, pathKey, colorHash, cullKey, 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);
// Adaptive density: drawAll recomputes the scale each frame from the number
// of animating links; rAF overlay redraws read the same M field.
const gap = Math.max(10, (+M.S.animGap || 36) * (M._animGapScale || 1));
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();
}
// --- overlay animation layer ---
//
// Animated flow markers render on a separate <canvas> stacked above the main
// canvas, so the ~30fps animation no longer forces a full graph redraw
// (setDirty) every frame — only the small overlay is repainted.
// Verified against frontend 1.45.21: the main canvas backing store is sized
// in CSS pixels (LGraphCanvas.resize uses parentElement.offsetWidth/Height),
// and the ctx transform during drawConnections is ds.toCanvasContext(ctx)
// (= scale(s,s) then translate(offset)). The frontend's own overlay uses the
// same recipe: setTransform(width/clientWidth) + ds.toCanvasContext.
function overlayUsable() {
return (
M.S.animOverlay !== false &&
!M._overlayFailed &&
typeof document !== "undefined"
);
}
function ensureOverlay(main) {
let ov = M._overlayCanvas;
try {
if (!ov) {
ov = document.createElement("canvas");
ov.dataset.linkrouterOverlay = "1";
ov.style.cssText =
"position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;";
M._overlayCanvas = ov;
M._overlayCtx = ov.getContext("2d");
if (!M._overlayCtx) throw new Error("2d context unavailable");
}
const parent = main?.parentElement;
if (!parent) return null;
if (ov.parentElement !== parent) {
// The 100%-sized overlay aligns with the main canvas only when the
// parent is a positioned ancestor (main canvas == parent size).
if (getComputedStyle(parent).position === "static")
parent.style.position = "relative";
parent.appendChild(ov);
}
return ov;
} catch (e) {
M._overlayFailed = true;
console.warn("[LinkRouter] overlay layer unavailable, legacy animation", e);
return null;
}
}
function clearOverlay() {
const ov = M._overlayCanvas;
const octx = M._overlayCtx;
if (!ov || !octx) return;
try {
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.clearRect(0, 0, ov.width, ov.height);
} catch {}
}
function drawOverlayFrame(lgCanvas) {
const main = lgCanvas?.canvas;
if (!main || !ensureOverlay(main)) return;
const ov = M._overlayCanvas;
const octx = M._overlayCtx;
try {
// Keep the overlay the top-most sibling so markers stay visible even if
// the frontend appends node layers later. No DOM write when already last.
const parent = main.parentElement;
if (parent && parent.lastElementChild !== ov) parent.appendChild(ov);
if (ov.width !== main.width || ov.height !== main.height) {
ov.width = main.width;
ov.height = main.height;
}
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.clearRect(0, 0, ov.width, ov.height);
const links = M._animLinks;
if (!links.length) return;
const dpr = main.width / (main.clientWidth || main.width || 1);
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
const ds = lgCanvas.ds;
if (ds) {
if (typeof ds.toCanvasContext === "function") ds.toCanvasContext(octx);
else {
octx.scale(ds.scale, ds.scale);
octx.translate(ds.offset[0], ds.offset[1]);
}
}
octx.lineJoin = "round";
octx.lineCap = "round";
const t = performance.now() / 1000;
for (const it of links)
drawFlow(octx, it.cached, t, it.color, it.alpha, false);
} catch (e) {
M._overlayFailed = true;
console.warn("[LinkRouter] overlay draw failed, legacy animation", e);
}
}
// --- animation loop ---
function stopAnimLoop() {
M.animActive = false;
if (M.rafId) cancelAnimationFrame(M.rafId);
M.rafId = 0;
}
function animTick(now) {
if (!M.animActive) return;
const interval = 1000 / M.currentFPS();
if (now - M.lastFrame >= interval) {
M.lastFrame = now;
if (M._overlayLoop) {
// Overlay mode: repaint only the overlay layer. Self-terminate when the
// plugin got disabled (drawAll no longer runs to stop us) or the
// overlay failed (next drawAll will restart the legacy loop).
if (M.S.enabled === false || M._overlayFailed) {
stopAnimLoop();
clearOverlay();
return;
}
drawOverlayFrame(app.canvas);
} else {
app.canvas?.setDirty(true, true);
}
}
M.rafId = requestAnimationFrame(animTick);
}
function ensureAnimLoop(need, useOverlay) {
if (need && M.animActive && M._overlayLoop === useOverlay) return;
const wasActive = M.animActive;
const wasOverlay = M._overlayLoop;
if (wasActive) stopAnimLoop();
if (wasOverlay) clearOverlay(); // never leave a stale overlay frame behind
if (need) {
M.animActive = true;
M._overlayLoop = useOverlay;
M.lastFrame = 0;
M.rafId = requestAnimationFrame(animTick);
}
}
// --- 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 graph = canvas.graph;
// Prefer litegraph's own spatial query over the already-computed
// visible_nodes list instead of scanning every node with getBounding.
if (graph && typeof graph.getNodeOnPos === "function") {
const n = graph.getNodeOnPos(gx, gy, canvas.visible_nodes || graph._nodes);
return n ? n.id : null;
}
const nodes = 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 ---
// Exponential moving average that ignores invalid samples and seeds itself
// from the first valid one (NaN until then).
function emaSample(prev, sample, alpha = 0.2) {
if (!Number.isFinite(sample) || sample < 0) return prev;
return Number.isFinite(prev) ? prev + alpha * (sample - prev) : sample;
}
// The routing failsafe disables the plugin for the rest of the session.
// Tell the user visibly (frontend toast when available) and flag the
// floating-bar toggle until the plugin is re-enabled — a silent console.warn
// used to leave the canvas looking "mysteriously native".
function notifyRouteFailsafe(err) {
const reason = String(err?.message || err || "unknown error");
M.routeFailsafeReason = reason;
console.warn(
"[LinkRouter] routing failed — plugin disabled for this session, " +
"falling back to the native renderer. Cause:",
reason,
);
try {
const toast = app.extensionManager?.toast;
if (toast?.add) {
toast.add({
severity: "error",
summary: "LinkRouter disabled",
detail:
"Routing failed (" + reason + ") — native renderer active for this session.",
life: 10000,
});
}
} catch {}
// Immediate UI marker (refreshBar keeps it via M.routeFailsafeReason).
try {
const btn = M.barRefs?.toggle;
if (btn) {
btn.style.background = "#a33";
btn.title = "LinkRouter disabled: " + reason + "\nClick to re-enable.";
}
} catch {}
}
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;
// Mirror the native drawConnections contract (frontend 1.45.21
// LGraphCanvas.ts:5977): the clickable-link set is rebuilt every frame.
// Without this, link stroke clicking, the centre-marker menu, and link
// tooltips stay dead while the plugin owns drawConnections.
canvas.renderedPaths?.clear?.();
const profileFrame = profiler.beginFrame(canvas);
let routed;
try {
routed = routeAll(graph);
} catch (err) {
M.S.enabled = false;
notifyRouteFailsafe(err);
profiler.endFrame(profileFrame, { fallback: true });
return false;
}
if (routed === null) {
profiler.endFrame(profileFrame, { fallback: true });
return false;
}
// Routing is done for this frame; the remaining drawAll work is the pure
// draw cost that the adaptive progressive budget subtracts from the frame.
const routeEndedAt = performance.now();
const selRaw = canvas.selected_nodes;
// Frontend 1.47 made NodeId a runtime string ("12"); older builds use
// numbers (12). Object keys are always strings, so normalise BOTH sides to
// string instead of Number()-casting the keys — Number("…") on a string
// NodeId made every selection lookup silently miss after the 1.47 update.
const selIds = new Set(
(selRaw instanceof Map ? [...selRaw.keys()] : Object.keys(selRaw || {})).map(String),
);
const hovLinkId = M.S.hoverAnim ? M._hoverLinkId : null;
const hovSlotIds = M.S.hoverAnim ? M._hoverSlotLinkIds : null;
const hovSlotNodeId = M.S.hoverAnim ? M._hoverSlotNodeId : null;
// Slot-label hover (pointer on an input/output name or dot) wins over link
// hover and node hover: only that slot's links animate/thicken this frame.
const hovId =
M.S.hoverAnim && hovLinkId === null && !hovSlotIds ? hoverNodeId(canvas) : null;
const hoverId = hovId !== null && !selIds.has(String(hovId)) ? hovId : null;
const hasSel = M.S.selectHighlight && selIds.size > 0;
const isDragging = M._nodeDragActive;
const related = (link) =>
selIds.has(String(link.origin_id)) || selIds.has(String(link.target_id));
const isSlotLink = (link) => !!hovSlotIds && hovSlotIds.has(link.id);
// Slot-label hover on a SELECTED node narrows that node's highlight to the
// slot's links: its other related links lose the select boost/animation but
// keep FULL alpha (never dimmed). Selections on other nodes are untouched,
// and slot hover on an unselected node leaves every selection alone.
const slotNodeKey =
hovSlotIds &&
hovSlotNodeId !== null &&
hovSlotNodeId !== undefined &&
selIds.has(String(hovSlotNodeId))
? String(hovSlotNodeId)
: null;
const demotedSel = (link) =>
!!slotNodeKey &&
!isSlotLink(link) &&
(String(link.origin_id) === slotNodeKey || String(link.target_id) === slotNodeKey);
const hovered = (link) =>
link.id === hovLinkId ||
isSlotLink(link) ||
(hoverId !== null && (link.origin_id === hoverId || link.target_id === hoverId));
// While any hover target is active (slot label, single link, or an
// unselected node), selection ANIMATION pauses — selected links keep their
// width boost and full alpha, but only the hovered links animate.
const hovAny = hovLinkId !== null || !!hovSlotIds || hoverId !== null;
const selAnim = M.S.selectAnim && !hovAny;
// Adaptive marker density: count this frame's animating links once (cheap
// predicate pass, no geometry) and scale the marker gap accordingly.
M._animGapScale = animDensityScale(
M.S.flowMode === "none"
? 0
: routed.reduce(
(n, { entry }) =>
n +
(((hasSel && related(entry.link) && !demotedSel(entry.link)) && selAnim) ||
hovered(entry.link)
? 1
: 0),
0,
),
+M.S.animAdaptiveThreshold || 10,
M.S.animAdaptive !== false,
);
const animOK = M.animEnabledNow();
// Overlay mode: animated markers are collected during the main pass and
// drawn on the overlay layer afterwards (and by the rAF tick), instead of
// being painted into the main canvas with a 30fps full redraw.
M._animLinks.length = 0;
const useOverlay =
M.S.flowMode === "animated" &&
overlayUsable() &&
!!ensureOverlay(canvas.canvas);
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.
// Low-zoom LOD (mirrors the frontend's own low-quality mode): when zoomed
// far out, outlines and flow markers are visually indistinguishable but
// still cost stroke/fill time — skip them. canvas.low_quality is the
// frontend 1.45.21 getter (scale < min_font_size_for_lod/(text size*√dpr));
// fall back to a fixed 0.6 threshold on older frontends.
const lowQ =
canvas.low_quality === true ||
(canvas.low_quality === undefined && (canvas.ds?.scale ?? 1) < 0.6);
const wantOutline = M.S.outline && !lowQ;
const cull = cullRectFor(canvas);
const batches = !hasSel ? staticBatches(canvas, routed, cull) : null;
const drawItems = hoverDrawItems(routed, hoverId, !!batches, hovLinkId, hovSlotIds);
if (batches) {
const w = +M.S.lineWidth || 3;
if (wantOutline) {
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++;
}
// Batch mode merges many links into one Path2D per colour, so there is no
// exact per-link Path2D to hand to litegraph hit-testing (a merged path
// would cross-hit every link in the batch). Batch links therefore join
// renderedPaths with their centre (_pos) only — centre marker, link menu
// and tooltip keep working; stroke-level hit-testing falls back to the
// frontend's layout-store spatial index where it has data. Links drawn
// individually below do get their exact .path assigned.
for (const { entry, cached } of routed) {
const link = entry.link;
const pts = cached.pts;
const mid = pts[Math.floor(pts.length / 2)];
link._pos && ((link._pos[0] = mid.x), (link._pos[1] = mid.y));
if (cull && boundsOutside(linkBounds(cached), cull)) continue;
canvas.renderedPaths?.add?.(link);
}
}
for (const { entry, cached } of drawItems) {
const pts = cached.pts;
const link = entry.link;
const mid = pts[Math.floor(pts.length / 2)];
link._pos && ((link._pos[0] = mid.x), (link._pos[1] = mid.y));
if (cull && boundsOutside(linkBounds(cached), cull)) continue;
const isSel = hasSel && related(link) && !demotedSel(link);
const isHov = M.S.hoverAnim && hovered(link);
let alpha = 1;
// Hovered links and slot-demoted selected links keep full alpha — only
// truly unrelated links dim while a selection is active.
if (hasSel && !isSel && !isHov && !demotedSel(link)) {
// 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;
// Hovered links (node-hover or single-link hover) get the same width
// boost as selected ones — shared selectBoost multiplier on purpose.
const w = (+M.S.lineWidth || 3) * (isSel || isHov ? +M.S.selectBoost || 1.35 : 1);
const color = linkColor(canvas, link);
if (wantOutline) {
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++;
// Per-link stroke: register the link and hand litegraph the exact Path2D
// just stroked (graph coordinates — the same space litegraphLinkAdapter
// stores in linkSegment.path; processMouseDown hit-tests it via
// ctx.isPointInStroke(path, graphX*dpi, graphY*dpi)). cachedCanvasPath
// reuses the stroke path, so no extra Path2D is built for this.
canvas.renderedPaths?.add?.(link);
const hitPath = cachedCanvasPath(cached);
if (hitPath) link.path = hitPath;
if (!lowQ && ((isSel && selAnim) || isHov)) {
if (M.S.flowMode === "animated" && animOK) {
animOn = true;
if (useOverlay) M._animLinks.push({ cached, color, alpha });
else 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);
}
}
}
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, useOverlay);
if (useOverlay) {
// Sync the overlay with the main canvas we just painted (pan/zoom stay
// glued); empty animation set means the overlay must be blank.
if (animOn) drawOverlayFrame(canvas);
else clearOverlay();
}
profiler.endFrame(profileFrame, { links: routed.length, strokeCalls, batched: !!batches });
// Frame pacing EMAs for the adaptive progressive budget (routing.js):
// updated every frame, independent of the opt-in profiler. Draw time is
// measured after routeAll so the routing slice itself is not double
// counted as draw cost.
const frameEndedAt = performance.now();
M._drawMsEma = emaSample(M._drawMsEma, frameEndedAt - routeEndedAt);
M._frameIntervalMsEma = emaSample(
M._frameIntervalMsEma,
M._lastFrameEndedAt ? frameEndedAt - M._lastFrameEndedAt : NaN,
);
M._lastFrameEndedAt = frameEndedAt;
return true;
}