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
|
// Hexagonal body with interior fill
|
||||||
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
|
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
|
||||||
|
|
||||||
// Avatar: first letter of name centered inside hexagon
|
// Avatar: robohash image or fallback letter
|
||||||
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead');
|
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
||||||
|
|
||||||
// Breathing animation + spawn/waiting effects
|
// Breathing animation + spawn/waiting effects
|
||||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
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(
|
function drawAvatar(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
x: number,
|
x: number,
|
||||||
|
|
@ -218,10 +242,28 @@ function drawAvatar(
|
||||||
name: string,
|
name: string,
|
||||||
color: string,
|
color: string,
|
||||||
isLead: boolean,
|
isLead: boolean,
|
||||||
|
avatarUrl?: string,
|
||||||
): void {
|
): 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 letter = name.charAt(0).toUpperCase();
|
||||||
const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7);
|
const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7);
|
||||||
|
|
||||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GraphNode } from '../ports/types';
|
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 */
|
/** Column header info for rendering */
|
||||||
export interface KanbanColumnHeader {
|
export interface KanbanColumnHeader {
|
||||||
|
|
@ -125,10 +125,10 @@ export class KanbanLayoutEngine {
|
||||||
const colX = baseX + colIdx * columnWidth;
|
const colX = baseX + colIdx * columnWidth;
|
||||||
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
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({
|
headers.push({
|
||||||
label: config.label,
|
label: config.label,
|
||||||
x: colX + TASK_PILL.width / 2, // horizontally centered over pills
|
x: colX, // pill center = task.x = colX
|
||||||
y: baseY,
|
y: baseY,
|
||||||
color: config.color,
|
color: config.color,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ export interface GraphNode {
|
||||||
// ─── Member/Lead-specific ──────────────────────────────────────────────
|
// ─── Member/Lead-specific ──────────────────────────────────────────────
|
||||||
/** Agent role description */
|
/** Agent role description */
|
||||||
role?: string;
|
role?: string;
|
||||||
|
/** Avatar image URL (e.g., robohash) */
|
||||||
|
avatarUrl?: string;
|
||||||
/** Spawn lifecycle status */
|
/** Spawn lifecycle status */
|
||||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||||
/** Context window usage ratio (0..1), available for lead only */
|
/** Context window usage ratio (0..1), available for lead only */
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GraphDataPort,
|
GraphDataPort,
|
||||||
|
|
@ -128,6 +129,7 @@ export class TeamGraphAdapter {
|
||||||
state: data.isAlive ? 'active' : 'idle',
|
state: data.isAlive ? 'active' : 'idle',
|
||||||
color: data.config.color ?? undefined,
|
color: data.config.color ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
|
avatarUrl: agentAvatarUrl('team-lead', 64),
|
||||||
domainRef: { kind: 'lead', teamName },
|
domainRef: { kind: 'lead', teamName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +157,7 @@ export class TeamGraphAdapter {
|
||||||
color: member.color ?? undefined,
|
color: member.color ?? undefined,
|
||||||
role: member.role ?? undefined,
|
role: member.role ?? undefined,
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
|
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||||
domainRef: { kind: 'member', teamName, memberName: member.name },
|
domainRef: { kind: 'member', teamName, memberName: member.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue