agent-ecosystem/packages/agent-graph/src/canvas/draw-activity-lanes.ts

250 lines
6.8 KiB
TypeScript

import { COLORS } from '../constants/colors';
import { MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants';
import {
ACTIVITY_LANE,
getActivityAnchorTarget,
getActivityLaneBounds,
getVisibleActivityWindow,
isActivityOwner,
resolveActivityLaneSide,
} from '../layout/activityLane';
import type { GraphActivityItem, GraphNode } from '../ports/types';
import { truncateText } from './draw-misc';
import { drawPillShell } from './draw-pill-shell';
import { hexWithAlpha, measureTextCached } from './render-cache';
export function drawActivityLanes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
selectedNodeId: string | null,
hoveredNodeId: string | null,
focusNodeIds?: ReadonlySet<string> | null,
zoom = 1
): void {
if (zoom < 0.16) {
return;
}
const leadNode = nodes.find((node) => node.kind === 'lead' && node.x != null);
const leadX = leadNode?.x ?? null;
for (const node of nodes) {
if (!isActivityOwner(node) || node.x == null || node.y == null) {
continue;
}
const opacity = getLaneOpacity(node, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) {
continue;
}
const window = getVisibleActivityWindow(node.activityItems);
const overflowCount = node.activityOverflowCount ?? window.overflowCount;
if (window.items.length === 0 && overflowCount <= 0) {
continue;
}
const anchor = getActivityAnchorTarget({
nodeX: node.x,
nodeY: node.y,
nodeKind: node.kind,
leadX,
});
const bounds = getActivityLaneBounds(anchor.x, anchor.y);
const left = bounds.left;
const top = bounds.top;
const side = resolveActivityLaneSide({
nodeKind: node.kind,
nodeX: node.x,
leadX,
});
ctx.save();
ctx.globalAlpha = opacity;
drawLaneHeader(ctx, side, left, top);
const itemsTop = top + ACTIVITY_LANE.headerHeight;
for (let index = 0; index < window.items.length; index += 1) {
const itemTop = itemsTop + index * ACTIVITY_LANE.rowHeight;
drawActivityPill(ctx, {
item: window.items[index],
x: left + ACTIVITY_LANE.width / 2,
y: itemTop + ACTIVITY_LANE.itemHeight / 2,
isOwnerSelected: node.id === selectedNodeId,
isOwnerHovered: node.id === hoveredNodeId,
});
}
if (overflowCount > 0) {
const overflowTop = itemsTop + window.items.length * ACTIVITY_LANE.rowHeight;
drawOverflowPill(
ctx,
left + ACTIVITY_LANE.width / 2,
overflowTop + ACTIVITY_LANE.overflowHeight / 2,
overflowCount
);
}
ctx.restore();
}
}
function getLaneOpacity(node: GraphNode, focusNodeIds?: ReadonlySet<string> | null): number {
if (focusNodeIds && !focusNodeIds.has(node.id)) {
return 0.25;
}
return 1;
}
function drawLaneHeader(
ctx: CanvasRenderingContext2D,
side: 'left' | 'right',
left: number,
top: number
): void {
ctx.font = 'bold 8px monospace';
ctx.textAlign = side === 'left' ? 'right' : 'left';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.46);
ctx.fillText('Activity', side === 'left' ? left + ACTIVITY_LANE.width : left, top);
}
function drawActivityPill(
ctx: CanvasRenderingContext2D,
params: {
item: GraphActivityItem;
x: number;
y: number;
isOwnerSelected: boolean;
isOwnerHovered: boolean;
}
): void {
const { item, x, y, isOwnerSelected, isOwnerHovered } = params;
const accent = getActivityAccentColor(item.kind);
const badgeText = getActivityBadgeText(item.kind);
ctx.save();
ctx.translate(x, y);
drawPillShell(ctx, {
width: ACTIVITY_LANE.width,
height: ACTIVITY_LANE.itemHeight,
radius: TASK_PILL.borderRadius,
fillStyle: isOwnerSelected
? COLORS.cardBgSelected
: isOwnerHovered
? 'rgba(15, 20, 40, 0.72)'
: COLORS.cardBg,
borderColor: hexWithAlpha(accent, isOwnerSelected ? 0.76 : 0.46),
borderWidth: isOwnerSelected ? 1.7 : 1,
shadowColor: hexWithAlpha(accent, 0.22),
shadowBlur: isOwnerSelected ? 10 : 6,
accentColor: hexWithAlpha(accent, 0.72),
});
ctx.font = 'bold 7px monospace';
const badgeWidth = Math.max(30, Math.ceil(measureTextCached(ctx, ctx.font, badgeText) + 14));
ctx.fillStyle = hexWithAlpha(accent, 0.16);
ctx.beginPath();
ctx.roundRect(
ACTIVITY_LANE.width / 2 - badgeWidth - 8,
-ACTIVITY_LANE.itemHeight / 2 + 4,
badgeWidth,
11,
5
);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(accent, 0.96);
ctx.fillText(
badgeText,
ACTIVITY_LANE.width / 2 - badgeWidth / 2 - 8,
-ACTIVITY_LANE.itemHeight / 2 + 9.5
);
ctx.textAlign = 'left';
ctx.font = 'bold 8px monospace';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
const title = truncateText(ctx, item.title, ACTIVITY_LANE.width - badgeWidth - 26, ctx.font);
ctx.fillText(title, -ACTIVITY_LANE.width / 2 + 10, -3.5);
const preview = item.preview?.trim();
if (preview) {
ctx.font = '7px monospace';
ctx.fillStyle = COLORS.textDim;
const previewText = truncateText(ctx, preview, ACTIVITY_LANE.width - 18, ctx.font);
ctx.fillText(previewText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
} else if (item.authorLabel) {
ctx.font = '7px monospace';
ctx.fillStyle = COLORS.textDim;
const authorText = truncateText(ctx, item.authorLabel, ACTIVITY_LANE.width - 18, ctx.font);
ctx.fillText(authorText, -ACTIVITY_LANE.width / 2 + 10, 6.5);
}
ctx.restore();
}
function drawOverflowPill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
overflowCount: number
): void {
const width = TASK_PILL.width;
const height = ACTIVITY_LANE.overflowHeight;
ctx.save();
ctx.translate(x, y);
drawPillShell(ctx, {
width,
height,
radius: 6,
fillStyle: 'rgba(10, 15, 30, 0.42)',
borderColor: hexWithAlpha(COLORS.holoBright, 0.22),
borderWidth: 1,
});
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.66);
ctx.fillText(`+${overflowCount} more`, 0, 0.5);
ctx.restore();
}
function getActivityAccentColor(kind: GraphActivityItem['kind']): string {
switch (kind) {
case 'task_comment':
return COLORS.particleTaskComment;
case 'task_assign':
return COLORS.particleTaskAssign;
case 'review_request':
return COLORS.particleReviewRequest;
case 'review_response':
return COLORS.particleReviewResponse;
case 'inbox_message':
default:
return COLORS.particleInboxMessage;
}
}
function getActivityBadgeText(kind: GraphActivityItem['kind']): string {
switch (kind) {
case 'task_comment':
return 'COMMENT';
case 'task_assign':
return 'TASK';
case 'review_request':
return 'REV';
case 'review_response':
return 'DONE';
case 'inbox_message':
default:
return 'MSG';
}
}