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:
parent
9fdbdd72d9
commit
d85058f198
4 changed files with 53 additions and 6 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue