diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts index 193651eaf28..d032a6f3100 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { agentMapDirectLineageChevronPath } from './agent-map-lineage-chevron-path' +import { + agentMapDirectLineageChevronPath, + agentMapLineagePathCacheSize +} from './agent-map-lineage-chevron-path' describe('agentMapDirectLineageChevronPath', () => { it('runs every chevron directly from the parent toward the child', () => { @@ -41,6 +44,50 @@ describe('agentMapDirectLineageChevronPath', () => { { x: 10_000, y: 0, radius: 0 } ) - expect(path.match(/\bM\b/g)).toHaveLength(32) + expect(path.match(/\bM\b/g)).toHaveLength(256) + }) + + it('keeps the same chevron pitch however far apart the nodes are', () => { + const pitches = [60, 200, 900].map((distance) => { + const tips = [ + ...agentMapDirectLineageChevronPath( + { x: 0, y: 0, radius: 0 }, + { x: distance, y: 0, radius: 0 } + ).matchAll(/M [-\d.]+ [-\d.]+ L ([-\d.]+) [-\d.]+ L/g) + ].map((match) => Number(match[1])) + + expect(tips.length).toBeGreaterThan(2) + return tips.slice(1).map((tip, index) => tip - tips[index]) + }) + + expect(pitches.flat().every((pitch) => pitch === 8)).toBe(true) + }) + + it('serves an unmoved edge from cache instead of rebuilding it', () => { + const parent = { x: 3, y: 5, radius: 20 } + const child = { x: 903, y: 5, radius: 20 } + const before = agentMapLineagePathCacheSize() + const first = agentMapDirectLineageChevronPath(parent, child) + const afterMiss = agentMapLineagePathCacheSize() + const second = agentMapDirectLineageChevronPath({ ...parent }, { ...child }) + + expect(afterMiss).toBe(before + 1) // first call was a miss + expect(agentMapLineagePathCacheSize()).toBe(afterMiss) // second stored nothing → hit + expect(second).toBe(first) + }) + + it('bounds the cache as edges churn', () => { + const hot = [ + { x: -1, y: -1, radius: 2 }, + { x: -1, y: -400, radius: 2 } + ] as const + for (let i = 0; i < 600; i += 1) { + agentMapDirectLineageChevronPath({ x: i, y: 1_000, radius: 2 }, { x: i, y: 1_200, radius: 2 }) + if (i % 10 === 0) { + agentMapDirectLineageChevronPath(hot[0], hot[1]) + } + } + + expect(agentMapLineagePathCacheSize()).toBe(512) }) }) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts index f1602805b2b..c7645fca18e 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-lineage-chevron-path.ts @@ -17,7 +17,7 @@ type LineageSegment = { const CHEVRON_SPACING = 8 const CHEVRON_DEPTH = 3.5 const CHEVRON_HALF_WIDTH = 2.25 -const MAX_CHEVRONS_PER_PATH = 32 +const MAX_CHEVRONS_PER_PATH = 256 function svgNumber(value: number): number { return Math.round(value * 1_000) / 1_000 @@ -46,11 +46,14 @@ export function agentMapLineageChevronPath(points: AgentMapLineagePoint[]): stri MAX_CHEVRONS_PER_PATH, Math.max(1, Math.floor(totalLength / CHEVRON_SPACING)) ) + // Fixed pitch, centered run: spacing must read identically on a short link and a long + // one. Dividing the length by the count instead stretched the gaps as nodes moved apart. + const firstDistance = (totalLength - (chevronCount - 1) * CHEVRON_SPACING) / 2 const commands: string[] = [] let segmentIndex = 0 let segmentStartDistance = 0 for (let index = 0; index < chevronCount; index += 1) { - const distance = (totalLength * (index + 1)) / (chevronCount + 1) + const distance = firstDistance + index * CHEVRON_SPACING while ( segmentIndex < segments.length - 1 && distance > segmentStartDistance + segments[segmentIndex].length @@ -73,7 +76,7 @@ export function agentMapLineageChevronPath(points: AgentMapLineagePoint[]): stri return commands.join(' ') } -export function agentMapDirectLineageChevronPath( +function buildDirectLineageChevronPath( parent: AgentMapLineageNode, child: AgentMapLineageNode ): string { @@ -90,3 +93,38 @@ export function agentMapDirectLineageChevronPath( { x: child.x - unitX * child.radius, y: child.y - unitY * child.radius } ]) } + +// Keyed on world coordinates, which a zoom gesture never changes — so the scene's +// per-frame rerender reuses every path instead of rebuilding kilobytes of `d` at +// 60fps. Only enter/exit motion, which really does move nodes, misses. LRU-bounded +// because a removed agent's key is never revisited. +const MAX_CACHED_LINEAGE_PATHS = 512 +const lineagePathCache = new Map() + +export function agentMapDirectLineageChevronPath( + parent: AgentMapLineageNode, + child: AgentMapLineageNode +): string { + const key = `${parent.x},${parent.y},${parent.radius},${child.x},${child.y},${child.radius}` + const cached = lineagePathCache.get(key) + if (cached !== undefined) { + lineagePathCache.delete(key) + lineagePathCache.set(key, cached) + return cached + } + const path = buildDirectLineageChevronPath(parent, child) + lineagePathCache.set(key, path) + while (lineagePathCache.size > MAX_CACHED_LINEAGE_PATHS) { + const oldest = lineagePathCache.keys().next().value + if (oldest === undefined) { + break + } + lineagePathCache.delete(oldest) + } + return path +} + +/** The cache is module-private, so its bound is only observable through here. */ +export function agentMapLineagePathCacheSize(): number { + return lineagePathCache.size +}