From 67ebdd9b64eb5354cfdbb66b664af03d51dcd641 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:28:28 -0700 Subject: [PATCH] fix(dashboard): hold agent map orchestration chevrons at a fixed pitch CHEVRON_SPACING only chose how many chevrons to draw; placement then divided the edge evenly, so the pitch grew with the distance between agents and, past the 32-chevron cap, grew without bound. Step at a literal 8px pitch instead, centering the run so it never overhangs either node. Fixed pitch makes length drive the chevron count, so cap coverage now decides how far the run reaches: at 32 it spanned only 248px and every longer link would have shown a chevron cluster stranded mid-edge. Raise it to 256 (~2048px, past any real in-project link) and memoize the generated path per endpoint coordinates, since the scene rebuilds every path string on each zoom frame and on 4Hz snapshot refreshes while world positions stay put. --- .../agent-map-lineage-chevron-path.test.ts | 51 ++++++++++++++++++- .../agent-map-lineage-chevron-path.ts | 44 ++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) 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 +}