Force-directed graph visualization for agent teams. Package: @claude-teams/agent-graph (isolated workspace package) - Space theme: bloom, particles, hex grid, depth stars - Members as hexagonal nodes with breathing glow - Tasks as pill cards in kanban columns (todo/wip/done/review/approved) per owner - Message particles along edges (real-time only) - Deterministic layout, Figma-style pan, scroll/pinch zoom - Clean Architecture: ports/adapters/strategies, ES #private classes Integration: features/agent-graph/ (adapter + overlay + tab) - Full-screen overlay (Cmd+Shift+G) + Pin as Tab - Graph button in Team section header - Frustum culling, zero per-frame allocations, adaptive fps - Performance overlay via ?perf query param Also: CI runs on all PR branches, features/CLAUDE.md architecture guide
140 lines
4.4 KiB
TypeScript
140 lines
4.4 KiB
TypeScript
/**
|
|
* Pre-rendered sprite cache for Canvas 2D glow effects.
|
|
* Adapted from agent-flow (Apache 2.0).
|
|
*/
|
|
|
|
const glowCache = new Map<string, HTMLCanvasElement>();
|
|
const textCache = new Map<string, number>();
|
|
const TEXT_CACHE_LIMIT = 2000;
|
|
|
|
// ─── Color resolution: named colors → hex ───────────────────────────────────
|
|
|
|
let _resolverCtx: CanvasRenderingContext2D | null = null;
|
|
const _hexCache = new Map<string, string>();
|
|
|
|
/**
|
|
* Ensure a color string is in #rrggbb hex format.
|
|
* Resolves CSS named colors (purple → #800080), shorthand (#abc → #aabbcc).
|
|
*/
|
|
function ensureHex(color: string): string {
|
|
if (color.startsWith('#') && color.length === 7) return color;
|
|
|
|
let hex = _hexCache.get(color);
|
|
if (hex) return hex;
|
|
|
|
if (color.startsWith('#') && color.length === 4) {
|
|
hex = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
|
|
} else {
|
|
// Resolve named/rgb/hsl colors via canvas
|
|
_resolverCtx ??= document.createElement('canvas').getContext('2d')!;
|
|
_resolverCtx.fillStyle = '#000000';
|
|
_resolverCtx.fillStyle = color;
|
|
hex = _resolverCtx.fillStyle; // always returns #rrggbb
|
|
}
|
|
|
|
_hexCache.set(color, hex);
|
|
return hex;
|
|
}
|
|
|
|
/** Build a hex color with alpha: "#rrggbbaa" — cached for repeated calls */
|
|
const _hexAlphaCache = new Map<string, string>();
|
|
function hexWithAlpha(color: string, alpha: number): string {
|
|
// Quantize alpha to 1/255 steps for cache hit rate
|
|
const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255);
|
|
const key = `${color}|${a}`;
|
|
let result = _hexAlphaCache.get(key);
|
|
if (result) return result;
|
|
result = ensureHex(color) + ALPHA_LUT[a];
|
|
_hexAlphaCache.set(key, result);
|
|
if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth
|
|
return result;
|
|
}
|
|
|
|
// Import-time LUT for alpha hex
|
|
const ALPHA_LUT: string[] = [];
|
|
for (let i = 0; i < 256; i++) ALPHA_LUT.push(i.toString(16).padStart(2, '0'));
|
|
|
|
// ─── Glow Sprite Cache ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get or create a pre-rendered radial gradient glow sprite.
|
|
*/
|
|
export function getGlowSprite(
|
|
color: string,
|
|
radius: number,
|
|
innerAlpha: number,
|
|
outerAlpha: number,
|
|
): HTMLCanvasElement {
|
|
const hex = ensureHex(color);
|
|
const key = `${hex}|${radius}|${innerAlpha}|${outerAlpha}`;
|
|
let canvas = glowCache.get(key);
|
|
if (canvas) return canvas;
|
|
|
|
const size = Math.ceil(radius * 2);
|
|
canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d')!;
|
|
const cx = size / 2;
|
|
|
|
const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, radius);
|
|
grad.addColorStop(0, hexWithAlpha(hex, innerAlpha));
|
|
grad.addColorStop(1, hexWithAlpha(hex, outerAlpha));
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
glowCache.set(key, canvas);
|
|
return canvas;
|
|
}
|
|
|
|
/**
|
|
* Get or create a pre-rendered agent glow sprite (inner + outer radius).
|
|
*/
|
|
export function getAgentGlowSprite(
|
|
color: string,
|
|
innerRadius: number,
|
|
outerRadius: number,
|
|
): HTMLCanvasElement {
|
|
const hex = ensureHex(color);
|
|
const key = `agent|${hex}|${innerRadius}|${outerRadius}`;
|
|
let canvas = glowCache.get(key);
|
|
if (canvas) return canvas;
|
|
|
|
const size = Math.ceil(outerRadius * 2);
|
|
canvas = document.createElement('canvas');
|
|
canvas.width = size;
|
|
canvas.height = size;
|
|
const ctx = canvas.getContext('2d')!;
|
|
const cx = size / 2;
|
|
|
|
const grad = ctx.createRadialGradient(cx, cx, innerRadius, cx, cx, outerRadius);
|
|
grad.addColorStop(0, hexWithAlpha(hex, 0.25));
|
|
grad.addColorStop(0.5, hexWithAlpha(hex, 0.08));
|
|
grad.addColorStop(1, hexWithAlpha(hex, 0));
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, size, size);
|
|
|
|
glowCache.set(key, canvas);
|
|
return canvas;
|
|
}
|
|
|
|
/**
|
|
* Cached text width measurement.
|
|
*/
|
|
export function measureTextCached(ctx: CanvasRenderingContext2D, font: string, text: string): number {
|
|
const key = `${font}|${text}`;
|
|
let w = textCache.get(key);
|
|
if (w !== undefined) return w;
|
|
|
|
if (textCache.size > TEXT_CACHE_LIMIT) textCache.clear();
|
|
|
|
const prevFont = ctx.font;
|
|
ctx.font = font;
|
|
w = ctx.measureText(text).width;
|
|
ctx.font = prevFont;
|
|
textCache.set(key, w);
|
|
return w;
|
|
}
|
|
|
|
/** Exported for use by draw functions that need hex+alpha colors */
|
|
export { ensureHex, hexWithAlpha };
|