mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
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.
This commit is contained in:
+49
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, string>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user