feat(graph-ui): animate transient memory highlights

This commit is contained in:
youzini
2026-06-04 08:20:16 +00:00
parent 6094b2bbc8
commit 8789f60541
3 changed files with 407 additions and 5 deletions

View File

@@ -4,9 +4,28 @@ globalThis.window = {
devicePixelRatio: 1,
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
};
globalThis.performance ??= { now: () => Date.now() };
globalThis.requestAnimationFrame = (callback) => setTimeout(() => callback(performance.now()), 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
let mockNow = 1000;
globalThis.performance = { now: () => mockNow };
let rafId = 0;
const rafCallbacks = new Map();
let timerId = 0;
const timerCallbacks = new Map();
globalThis.requestAnimationFrame = (callback) => {
const id = ++rafId;
rafCallbacks.set(id, callback);
return id;
};
globalThis.cancelAnimationFrame = (id) => {
rafCallbacks.delete(id);
};
globalThis.setTimeout = (callback, delay = 0) => {
const id = ++timerId;
timerCallbacks.set(id, { callback, dueAt: mockNow + Math.max(0, Number(delay) || 0) });
return id;
};
globalThis.clearTimeout = (id) => {
timerCallbacks.delete(id);
};
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
disconnect() {}
@@ -15,8 +34,31 @@ globalThis.ResizeObserver = class ResizeObserver {
const canvasMockStats = {
radialGradientCalls: 0,
linearGradientCalls: 0,
strokeCalls: 0,
};
function flushNextRaf(ms = 16) {
const [id, callback] = rafCallbacks.entries().next().value || [];
if (!id) return false;
rafCallbacks.delete(id);
mockNow += ms;
callback(mockNow);
return true;
}
function advanceMockTime(ms = 0) {
mockNow += ms;
let ran = false;
for (const [id, timer] of [...timerCallbacks.entries()].sort((a, b) => a[1].dueAt - b[1].dueAt)) {
if (timer.dueAt <= mockNow) {
timerCallbacks.delete(id);
timer.callback();
ran = true;
}
}
return ran;
}
function createNoopContext() {
const noop = () => {};
return {
@@ -30,7 +72,9 @@ function createNoopContext() {
arc: noop,
arcTo: noop,
fill: noop,
stroke: noop,
stroke: () => {
canvasMockStats.strokeCalls += 1;
},
moveTo: noop,
lineTo: noop,
quadraticCurveTo: noop,
@@ -57,6 +101,8 @@ function createNoopContext() {
set globalAlpha(_value) {},
set lineCap(_value) {},
set lineJoin(_value) {},
set shadowColor(_value) {},
set shadowBlur(_value) {},
};
}
@@ -101,6 +147,12 @@ function assertInputUnchanged(graph, beforeJson) {
}
}
function resetCanvasStats() {
canvasMockStats.radialGradientCalls = 0;
canvasMockStats.linearGradientCalls = 0;
canvasMockStats.strokeCalls = 0;
}
const { GraphRenderer } = await import("../ui/graph-renderer.js");
{
@@ -211,4 +263,85 @@ const { GraphRenderer } = await import("../ui/graph-renderer.js");
renderer.destroy();
}
{
resetCanvasStats();
const graph = createGraphFixture();
const before = JSON.stringify(graph);
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
renderer.setTransientHighlights({
recallNodeIds: ["objective-1", { nodeId: "user-1" }],
extractedNodeIds: [{ id: "char-1" }, "objective-1"],
ttlMs: 80,
reason: "test",
});
assertInputUnchanged(graph, before);
let diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.count, 3);
assert.equal(diagnostics.activeCount, 3);
assert.equal(diagnostics.reducedMotion, false);
assert.ok(flushNextRaf());
assert.ok(canvasMockStats.radialGradientCalls > 0);
assert.ok(canvasMockStats.strokeCalls > 0);
diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.count, 3);
mockNow += 120;
diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.count, 0);
assert.equal(diagnostics.animationScheduled, false);
renderer.destroy();
}
{
const graph = createGraphFixture();
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
renderer.setTransientHighlights({ recallNodeIds: ["objective-1"], ttlMs: 1000 });
assert.equal(renderer.getTransientHighlightDiagnostics().count, 1);
renderer.setEnabled(false);
const diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.count, 0);
assert.equal(diagnostics.animationScheduled, false);
renderer.destroy();
}
{
const previousMatchMedia = globalThis.window.matchMedia;
globalThis.window.matchMedia = () => ({ matches: true, addEventListener() {}, removeEventListener() {} });
const graph = createGraphFixture();
const before = JSON.stringify(graph);
const renderer = new GraphRenderer(createCanvas(), {
runtimeConfig: { graphUseNativeLayout: false, graphNativeForceDisable: true },
layoutConfig: { neuralIterations: 8 },
});
renderer.loadGraph(graph, { userPovAliases: ["Host"] });
resetCanvasStats();
renderer.setTransientHighlights({ recallNodeIds: ["objective-1"], ttlMs: 1000 });
flushNextRaf();
let diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.reducedMotion, true);
assert.equal(diagnostics.count, 1);
assert.equal(diagnostics.animationScheduled, false);
assert.equal(diagnostics.expiryScheduled, true);
assert.ok(canvasMockStats.strokeCalls > 0);
advanceMockTime(1002);
assert.ok(flushNextRaf());
diagnostics = renderer.getTransientHighlightDiagnostics();
assert.equal(diagnostics.count, 0);
assert.equal(diagnostics.animationScheduled, false);
assert.equal(diagnostics.expiryScheduled, false);
assertInputUnchanged(graph, before);
renderer.destroy();
globalThis.window.matchMedia = previousMatchMedia;
}
console.log("graph-renderer guardrail tests passed");

View File

@@ -359,6 +359,9 @@ export class GraphRenderer {
this._suppressMouseUntil = 0;
this.animId = null;
this._highlightAnimId = null;
this._highlightExpiryTimer = null;
this._transientHighlights = new Map();
this.enabled = true;
// Callbacks
@@ -416,6 +419,7 @@ export class GraphRenderer {
const rawPartitionCounts = countRawNodesByScope(activeRawNodes, this._userPovAliasSet);
if (!this.enabled) {
this._clearTransientHighlights({ cancelAnimation: true });
this._setLastLayoutDiagnostics({
mode: 'skipped',
nodeCount: 0,
@@ -545,6 +549,8 @@ export class GraphRenderer {
this.selectedNode = this.nodeMap.get(prevSelectedId) || null;
}
this._pruneTransientHighlights(this._nowMs());
this._cancelAnim();
this._render();
@@ -589,6 +595,69 @@ export class GraphRenderer {
if (this.enabled) this._render();
}
setTransientHighlights(payload = {}) {
const wasActive = this._transientHighlights?.size > 0;
const ttlMs = Math.max(1, Math.min(60000, Number(payload?.ttlMs) || 1800));
const reason = String(payload?.reason || '').trim();
const now = this._nowMs();
const next = new Map();
const recallIds = this._normalizeTransientHighlightIds(payload?.recallNodeIds);
const extractedIds = this._normalizeTransientHighlightIds(payload?.extractedNodeIds);
for (const id of extractedIds) {
next.set(id, {
kind: 'extracted',
startedAt: now,
expiresAt: now + ttlMs,
ttlMs,
reason,
});
}
for (const id of recallIds) {
const existing = next.get(id);
next.set(id, {
kind: existing ? 'mixed' : 'recall',
startedAt: now,
expiresAt: now + ttlMs,
ttlMs,
reason,
});
}
this._transientHighlights = next;
this._cancelHighlightAnimationFrame();
this._cancelHighlightExpiryTimer();
if (!this.enabled) {
this._clearTransientHighlights({ cancelAnimation: true });
return;
}
if (next.size > 0) {
this._scheduleRender();
if (this._isReducedMotion()) {
this._scheduleReducedMotionHighlightExpiry();
}
} else if (wasActive) {
this._scheduleRender();
}
}
getTransientHighlightDiagnostics() {
const now = this._nowMs();
this._pruneTransientHighlights(now);
if (this._transientHighlights.size <= 0) {
this._cancelHighlightAnimationFrame();
this._cancelHighlightExpiryTimer();
}
return {
count: this._transientHighlights.size,
activeCount: this._transientHighlights.size,
reducedMotion: this._isReducedMotion(),
animationScheduled: !!this._highlightAnimId,
expiryScheduled: !!this._highlightExpiryTimer,
};
}
setRuntimeConfig(runtimeConfig = {}) {
this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
if (this._nativeLayoutBridge) {
@@ -635,7 +704,10 @@ export class GraphRenderer {
setEnabled(enabled = true) {
const nextEnabled = enabled !== false;
if (this.enabled === nextEnabled) {
if (!nextEnabled) this._clearCanvas();
if (!nextEnabled) {
this._clearTransientHighlights({ cancelAnimation: true });
this._clearCanvas();
}
return;
}
this._nextLayoutSolveRevision();
@@ -652,6 +724,7 @@ export class GraphRenderer {
this._dragStartMouse = null;
this.hoveredNode = null;
if (!nextEnabled) {
this._clearTransientHighlights({ cancelAnimation: true });
this.nodeMap.clear();
this.nodes = [];
this.edges = [];
@@ -1378,6 +1451,7 @@ export class GraphRenderer {
const dpr = window.devicePixelRatio || 1;
const W = this.canvas.width / dpr;
const H = this.canvas.height / dpr;
this._pruneTransientHighlights(this._nowMs());
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@@ -1406,6 +1480,7 @@ export class GraphRenderer {
const isHovered = node === this.hoveredNode;
const isDimmed = focus.selectedNode && !focus.connectedNodes.has(node);
const r = (isSelected ? baseRadius * 1.12 : baseRadius) * (isDimmed ? 0.6 : 1);
const transientHighlight = this._transientHighlights.get(node.id) || null;
const scope = normalizeMemoryScope(node.raw?.scope);
const outlineColor = scope.layer === 'pov'
? (scope.ownerType === 'user'
@@ -1416,6 +1491,10 @@ export class GraphRenderer {
ctx.save();
if (isDimmed) ctx.globalAlpha = 0.2;
if (transientHighlight) {
this._drawTransientHighlight(ctx, node, r, transientHighlight);
}
if (isSelected || isHovered) {
const glowRadius = r + (isSelected ? 20 : 13);
ctx.beginPath();
@@ -1512,6 +1591,71 @@ export class GraphRenderer {
}
ctx.restore();
this._afterRenderTransientHighlights();
}
_drawTransientHighlight(ctx, node, radius, highlight) {
if (!highlight || !node) return;
const now = this._nowMs();
const ttl = Math.max(1, Number(highlight.ttlMs) || 1);
const progress = Math.max(0, Math.min(1, (now - Number(highlight.startedAt || now)) / ttl));
const reducedMotion = this._isReducedMotion();
const phase = reducedMotion ? 0.55 : (Math.sin(progress * Math.PI * 4) + 1) / 2;
const fade = Math.max(0, 1 - progress);
const kind = highlight.kind || 'recall';
const drawPulse = (color, offset, alphaScale = 1) => {
const pulse = reducedMotion ? 0.35 : phase;
const ringRadius = radius + offset + pulse * 11;
const glowRadius = ringRadius + 11;
ctx.beginPath();
ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = createCanvasGradient(
ctx,
'createRadialGradient',
[node.x, node.y, Math.max(1, radius * 0.8), node.x, node.y, glowRadius],
[
[0, colorWithAlpha(color, 0.10 * fade * alphaScale)],
[0.58, colorWithAlpha(color, 0.16 * fade * alphaScale)],
[1, colorWithAlpha(color, 0)],
],
colorWithAlpha(color, 0.08 * fade * alphaScale),
);
ctx.fill();
ctx.beginPath();
ctx.arc(node.x, node.y, ringRadius, 0, Math.PI * 2);
ctx.strokeStyle = colorWithAlpha(color, (0.28 + phase * 0.34) * fade * alphaScale);
ctx.lineWidth = 1.2 + phase * 1.4;
ctx.stroke();
};
if (kind === 'mixed') {
drawPulse('#7cf8ff', 7, 1);
drawPulse('#b79cff', 14, 0.88);
} else if (kind === 'extracted') {
drawPulse('#b79cff', 9, 0.9);
drawPulse('#75ffb1', 15, 0.5);
} else {
drawPulse('#7cf8ff', 8, 1);
}
}
_afterRenderTransientHighlights() {
if (!this.enabled) {
this._clearTransientHighlights({ cancelAnimation: true });
return;
}
const hasActive = this._pruneTransientHighlights(this._nowMs()) > 0;
if (!hasActive) {
this._cancelHighlightAnimationFrame();
this._cancelHighlightExpiryTimer();
return;
}
if (!this._isReducedMotion()) {
this._scheduleHighlightAnimationFrame();
} else {
this._scheduleReducedMotionHighlightExpiry();
}
}
_drawDeepSpaceBackground(ctx, W, H) {
@@ -1616,6 +1760,105 @@ export class GraphRenderer {
return { selectedNode, connectedNodes };
}
_nowMs() {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
_isReducedMotion() {
try {
return window?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
} catch {
return false;
}
}
_normalizeTransientHighlightIds(input) {
const source = Array.isArray(input) ? input : (input == null ? [] : [input]);
const ids = new Set();
for (const item of source) {
let raw = item;
if (item && typeof item === 'object') {
raw = item.id ?? item.nodeId;
}
if (raw == null) continue;
const id = String(raw).trim();
if (id) ids.add(id);
}
return ids;
}
_pruneTransientHighlights(now = this._nowMs()) {
for (const [nodeId, highlight] of this._transientHighlights) {
if (!this.nodeMap.has(nodeId) || Number(highlight?.expiresAt || 0) <= now) {
this._transientHighlights.delete(nodeId);
}
}
return this._transientHighlights.size;
}
_clearTransientHighlights({ cancelAnimation = false } = {}) {
this._transientHighlights.clear();
if (cancelAnimation) {
this._cancelHighlightAnimationFrame();
this._cancelHighlightExpiryTimer();
}
}
_cancelHighlightAnimationFrame() {
if (this._highlightAnimId) {
cancelAnimationFrame(this._highlightAnimId);
this._highlightAnimId = null;
}
}
_cancelHighlightExpiryTimer() {
if (this._highlightExpiryTimer) {
clearTimeout(this._highlightExpiryTimer);
this._highlightExpiryTimer = null;
}
}
_scheduleReducedMotionHighlightExpiry() {
if (!this.enabled || this._highlightExpiryTimer || !this._isReducedMotion()) return;
if (this._transientHighlights.size <= 0) return;
const now = this._nowMs();
let nextExpiresAt = Infinity;
for (const highlight of this._transientHighlights.values()) {
const expiresAt = Number(highlight?.expiresAt || 0);
if (expiresAt > now) nextExpiresAt = Math.min(nextExpiresAt, expiresAt);
}
if (!Number.isFinite(nextExpiresAt)) return;
const delay = Math.max(1, Math.ceil(nextExpiresAt - now) + 1);
this._highlightExpiryTimer = setTimeout(() => {
this._highlightExpiryTimer = null;
if (!this.enabled) {
this._clearTransientHighlights({ cancelAnimation: true });
return;
}
const hadHighlights = this._transientHighlights.size > 0;
const active = this._pruneTransientHighlights(this._nowMs());
if (hadHighlights) this._scheduleRender();
if (active > 0) this._scheduleReducedMotionHighlightExpiry();
}, delay);
}
_scheduleHighlightAnimationFrame() {
if (!this.enabled || this._highlightAnimId || this._isReducedMotion()) return;
if (this._transientHighlights.size <= 0) return;
this._highlightAnimId = requestAnimationFrame(() => {
this._highlightAnimId = null;
if (!this.enabled) {
this._clearTransientHighlights({ cancelAnimation: true });
return;
}
if (this._pruneTransientHighlights(this._nowMs()) <= 0) return;
this._render();
});
}
_scheduleRender() {
if (!this.enabled || this.animId) return;
this.animId = requestAnimationFrame(() => {
@@ -1960,6 +2203,7 @@ export class GraphRenderer {
destroy() {
this._nextLayoutSolveRevision();
this._cancelAnim();
this._clearTransientHighlights({ cancelAnimation: true });
this._nativeLayoutBridge?.dispose?.();
this._nativeLayoutBridge = null;
recordGraphLayoutDebugSnapshot(

View File

@@ -361,6 +361,8 @@ let pendingVisibleGraphRefreshToken = "";
let pendingVisibleGraphRefreshForce = false;
let lastVisibleGraphRefreshToken = "";
let lastVisibleGraphRefreshAt = 0;
let lastGraphTransientHighlightSignature = "";
let lastGraphTransientHighlightRenderer = null;
let graphRenderingEnabled = true;
function _isPluginEnabled(settings = _getSettings?.() || {}) {
@@ -859,6 +861,7 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) {
if (visibleMode === "desktop:graph") {
if (graph && graphRenderer) {
graphRenderer.loadGraph(graph, hints);
_syncGraphTransientHighlights({ renderer: graphRenderer, visibleMode });
}
} else if (visibleMode === "desktop:cognition") {
_refreshCognitionWorkspace();
@@ -867,6 +870,7 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) {
} else if (visibleMode === "mobile:graph") {
if (graph && mobileGraphRenderer) {
mobileGraphRenderer.loadGraph(graph, hints);
_syncGraphTransientHighlights({ renderer: mobileGraphRenderer, visibleMode });
}
_buildMobileLegend();
} else if (visibleMode === "mobile:cognition") {
@@ -887,6 +891,26 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) {
};
}
function _syncGraphTransientHighlights({ renderer = null, visibleMode = null, force = false } = {}) {
const mode = visibleMode || _getVisibleGraphWorkspaceMode();
if (mode !== "desktop:graph" && mode !== "mobile:graph") return;
const targetRenderer = renderer || (mode === "mobile:graph" ? mobileGraphRenderer : graphRenderer);
if (!targetRenderer?.setTransientHighlights) return;
const recallNodeIds = _getLastRecall?.() || [];
const extractedNodeIds = _getLastExtract?.() || [];
const signature = JSON.stringify({ mode, recallNodeIds, extractedNodeIds });
const rendererChanged = targetRenderer !== lastGraphTransientHighlightRenderer;
if (!force && !rendererChanged && signature === lastGraphTransientHighlightSignature) return;
lastGraphTransientHighlightSignature = signature;
lastGraphTransientHighlightRenderer = targetRenderer;
targetRenderer.setTransientHighlights({
recallNodeIds,
extractedNodeIds,
ttlMs: 1800,
reason: "panel-live-state",
});
}
function _flushScheduledVisibleGraphRefresh() {
const shouldForce = pendingVisibleGraphRefreshForce === true;
_clearScheduledVisibleGraphRefresh();
@@ -1334,6 +1358,7 @@ function _doRefreshLiveState() {
}
_scheduleVisibleGraphWorkspaceRefresh();
_syncGraphTransientHighlights();
}
function _refreshHideOldMessagesStatus(settings = _getSettings?.() || {}) {