diff --git a/tests/graph-renderer-guardrails.mjs b/tests/graph-renderer-guardrails.mjs index 68f7ad1..71770f4 100644 --- a/tests/graph-renderer-guardrails.mjs +++ b/tests/graph-renderer-guardrails.mjs @@ -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"); diff --git a/ui/graph-renderer.js b/ui/graph-renderer.js index 990549f..99482f3 100644 --- a/ui/graph-renderer.js +++ b/ui/graph-renderer.js @@ -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( diff --git a/ui/panel.js b/ui/panel.js index e57d7f2..273f85d 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -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?.() || {}) {