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
158 lines
4.5 KiB
TypeScript
158 lines
4.5 KiB
TypeScript
/**
|
|
* Background rendering: depth star field + hex grid.
|
|
* Adapted from agent-flow's background-layer.ts (Apache 2.0).
|
|
*/
|
|
|
|
import { COLORS, alphaHex } from '../constants/colors';
|
|
import { BACKGROUND } from '../constants/canvas-constants';
|
|
|
|
// ─── Depth Particle (star) ──────────────────────────────────────────────────
|
|
|
|
export interface DepthParticle {
|
|
x: number;
|
|
y: number;
|
|
size: number;
|
|
brightness: number;
|
|
speed: number;
|
|
depth: number;
|
|
}
|
|
|
|
export function createDepthParticles(w: number, h: number): DepthParticle[] {
|
|
const particles: DepthParticle[] = [];
|
|
for (let i = 0; i < BACKGROUND.starCount; i++) {
|
|
particles.push({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
size: 0.3 + Math.random() * 1.2,
|
|
brightness: 0.15 + Math.random() * 0.4,
|
|
speed: 0.05 + Math.random() * 0.15,
|
|
depth: Math.random(),
|
|
});
|
|
}
|
|
return particles;
|
|
}
|
|
|
|
export function updateDepthParticles(
|
|
particles: DepthParticle[],
|
|
w: number,
|
|
h: number,
|
|
dt: number,
|
|
): void {
|
|
for (const p of particles) {
|
|
p.y += p.speed * dt * 20;
|
|
if (p.y > h + 5) {
|
|
p.y = -5;
|
|
p.x = Math.random() * w;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Background Drawing ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Draw the space background: void fill + depth stars + optional hex grid.
|
|
*/
|
|
export function drawBackground(
|
|
ctx: CanvasRenderingContext2D,
|
|
w: number,
|
|
h: number,
|
|
particles: DepthParticle[],
|
|
camera: { x: number; y: number; zoom: number },
|
|
time: number,
|
|
options?: { showHexGrid?: boolean; showStarField?: boolean },
|
|
): void {
|
|
const showStars = options?.showStarField ?? true;
|
|
const showHex = options?.showHexGrid ?? true;
|
|
|
|
// Deep void background
|
|
ctx.fillStyle = COLORS.void;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// Depth star field
|
|
if (showStars) {
|
|
for (const p of particles) {
|
|
const parallax = 1 - p.depth * 0.3;
|
|
const sx = p.x + camera.x * parallax * 0.02;
|
|
const sy = p.y + camera.y * parallax * 0.02;
|
|
const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01);
|
|
const alpha = p.brightness * twinkle;
|
|
|
|
ctx.fillStyle = COLORS.holoBright + alphaHex(alpha);
|
|
ctx.beginPath();
|
|
ctx.arc(
|
|
((sx % w) + w) % w,
|
|
((sy % h) + h) % h,
|
|
p.size,
|
|
0,
|
|
Math.PI * 2,
|
|
);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Hex grid
|
|
if (showHex) {
|
|
drawHexGrid(ctx, w, h, camera, time);
|
|
}
|
|
}
|
|
|
|
// ─── Hex Grid ───────────────────────────────────────────────────────────────
|
|
|
|
// Pre-computed hex vertex offsets
|
|
const HEX_OFFSETS: [number, number][] = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const angle = (Math.PI / 3) * i - Math.PI / 6;
|
|
HEX_OFFSETS.push([Math.cos(angle), Math.sin(angle)]);
|
|
}
|
|
|
|
function drawHexGrid(
|
|
ctx: CanvasRenderingContext2D,
|
|
w: number,
|
|
h: number,
|
|
camera: { x: number; y: number; zoom: number },
|
|
time: number,
|
|
): void {
|
|
const size = BACKGROUND.hexSize;
|
|
const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed));
|
|
|
|
// Visible region in world space (expanded a bit for edge cells)
|
|
const worldX0 = -camera.x / camera.zoom - size * 2;
|
|
const worldY0 = -camera.y / camera.zoom - size * 2;
|
|
const worldX1 = (w - camera.x) / camera.zoom + size * 2;
|
|
const worldY1 = (h - camera.y) / camera.zoom + size * 2;
|
|
|
|
const rowH = size * 1.5;
|
|
const colW = size * Math.sqrt(3);
|
|
|
|
const rowStart = Math.floor(worldY0 / rowH);
|
|
const rowEnd = Math.ceil(worldY1 / rowH);
|
|
const colStart = Math.floor(worldX0 / colW);
|
|
const colEnd = Math.ceil(worldX1 / colW);
|
|
|
|
ctx.save();
|
|
ctx.translate(camera.x, camera.y);
|
|
ctx.scale(camera.zoom, camera.zoom);
|
|
|
|
ctx.strokeStyle = COLORS.hexGrid + alphaHex(pulse);
|
|
ctx.lineWidth = 0.5 / camera.zoom;
|
|
|
|
ctx.beginPath();
|
|
for (let row = rowStart; row <= rowEnd; row++) {
|
|
for (let col = colStart; col <= colEnd; col++) {
|
|
const cx = col * colW + (row % 2 === 0 ? 0 : colW / 2);
|
|
const cy = row * rowH;
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
const [ox, oy] = HEX_OFFSETS[i];
|
|
const px = cx + ox * size;
|
|
const py = cy + oy * size;
|
|
if (i === 0) ctx.moveTo(px, py);
|
|
else ctx.lineTo(px, py);
|
|
}
|
|
ctx.closePath();
|
|
}
|
|
}
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|