feat(graph): robohash avatars inside member hexagons + center column headers

- Member/lead nodes show robohash avatar image clipped to circle inside hex
- Async image loading with cache (fallback to first letter while loading)
- avatarUrl field added to GraphNode type + adapter passes agentAvatarUrl()
- Column headers now centered over pill center (was offset by half pill width)
This commit is contained in:
iliya 2026-03-28 12:46:25 +02:00
parent 9fdbdd72d9
commit d85058f198
4 changed files with 53 additions and 6 deletions

View file

@ -44,8 +44,8 @@ export function drawAgents(
// Hexagonal body with interior fill
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
// Avatar: first letter of name centered inside hexagon
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead');
// Avatar: robohash image or fallback letter
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
// Breathing animation + spawn/waiting effects
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
@ -210,6 +210,30 @@ function drawBreathing(
}
}
// ─── Avatar image cache ─────────────────────────────────────────────────────
const avatarCache = new Map<string, HTMLImageElement>();
const avatarLoading = new Set<string>();
function getAvatarImage(url: string): HTMLImageElement | null {
const cached = avatarCache.get(url);
if (cached) return cached;
if (avatarLoading.has(url)) return null;
avatarLoading.add(url);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
avatarCache.set(url, img);
avatarLoading.delete(url);
};
img.onerror = () => {
avatarLoading.delete(url);
};
img.src = url;
return null;
}
function drawAvatar(
ctx: CanvasRenderingContext2D,
x: number,
@ -218,10 +242,28 @@ function drawAvatar(
name: string,
color: string,
isLead: boolean,
avatarUrl?: string,
): void {
const avatarR = r * 0.6;
// Try to draw avatar image
if (avatarUrl) {
const img = getAvatarImage(avatarUrl);
if (img) {
ctx.save();
// Clip to circle inside hexagon
ctx.beginPath();
ctx.arc(x, y, avatarR, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, x - avatarR, y - avatarR, avatarR * 2, avatarR * 2);
ctx.restore();
return;
}
}
// Fallback: first letter
const letter = name.charAt(0).toUpperCase();
const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7);
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';

View file

@ -8,7 +8,7 @@
*/
import type { GraphNode } from '../ports/types';
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import { KANBAN_ZONE } from '../constants/canvas-constants';
/** Column header info for rendering */
export interface KanbanColumnHeader {
@ -125,10 +125,10 @@ export class KanbanLayoutEngine {
const colX = baseX + colIdx * columnWidth;
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
// Column header — centered over pill area
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
headers.push({
label: config.label,
x: colX + TASK_PILL.width / 2, // horizontally centered over pills
x: colX, // pill center = task.x = colX
y: baseY,
color: config.color,
});

View file

@ -43,6 +43,8 @@ export interface GraphNode {
// ─── Member/Lead-specific ──────────────────────────────────────────────
/** Agent role description */
role?: string;
/** Avatar image URL (e.g., robohash) */
avatarUrl?: string;
/** Spawn lifecycle status */
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
/** Context window usage ratio (0..1), available for lead only */

View file

@ -8,6 +8,7 @@
*/
import { isLeadMember } from '@shared/utils/leadDetection';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import type {
GraphDataPort,
@ -128,6 +129,7 @@ export class TeamGraphAdapter {
state: data.isAlive ? 'active' : 'idle',
color: data.config.color ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl('team-lead', 64),
domainRef: { kind: 'lead', teamName },
});
}
@ -155,6 +157,7 @@ export class TeamGraphAdapter {
color: member.color ?? undefined,
role: member.role ?? undefined,
spawnStatus: spawn?.status,
avatarUrl: agentAvatarUrl(member.name, 64),
domainRef: { kind: 'member', teamName, memberName: member.name },
});