Files
attune/web/src/components/executions/workflow-timeline/TimelineRenderer.tsx
2026-03-04 22:02:34 -06:00

1054 lines
32 KiB
TypeScript

/**
* TimelineRenderer — Pure SVG renderer for the Workflow Timeline DAG.
*
* Renders:
* - Vertical gridlines with time labels along the top
* - Task bars (horizontal rounded rectangles) colored by state
* - Milestone nodes (small diamonds)
* - Curved Bezier dependency edges with transition-aware coloring/labels
* - Hover tooltips with task details
* - Click-to-select with upstream/downstream path highlighting
*/
import { useState, useRef, useCallback, useMemo, useEffect } from "react";
import type {
ComputedLayout,
TimelineNode,
TimelineEdge,
TimelineTask,
TooltipData,
LayoutConfig,
EdgeKind,
WithItemsGroupInfo,
} from "./types";
import {
STATE_COLORS,
EDGE_KIND_COLORS,
MILESTONE_COLORS,
DEFAULT_LAYOUT,
} from "./types";
import { computeEdgePath, computeGridLines, type GridLine } from "./layout";
import { findConnectedPath, edgeKey } from "./data";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface TimelineRendererProps {
layout: ComputedLayout;
tasks: TimelineTask[];
config?: LayoutConfig;
/** Callback when a task bar is clicked (e.g. navigate to execution detail) */
onTaskClick?: (task: TimelineTask) => void;
/** Prefix for SVG element IDs to avoid collisions when multiple renderers coexist */
idPrefix?: string;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
const secs = ms / 1000;
if (secs < 60) return `${secs.toFixed(1)}s`;
const mins = Math.floor(secs / 60);
const remainSecs = Math.round(secs % 60);
if (mins < 60) return `${mins}m ${remainSecs}s`;
const hrs = Math.floor(mins / 60);
const remainMins = mins % 60;
return `${hrs}h ${remainMins}m`;
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function stateLabel(state: string): string {
return state.charAt(0).toUpperCase() + state.slice(1);
}
function edgeColor(edge: TimelineEdge): string {
if (edge.color) return edge.color;
return EDGE_KIND_COLORS[edge.kind] ?? EDGE_KIND_COLORS.always;
}
function edgeOpacity(
edge: TimelineEdge,
isHighlighted: boolean,
hasSelection: boolean,
): number {
if (!hasSelection) {
// No selection — show all edges at moderate opacity
return edge.kind === "failure" || edge.kind === "timeout" ? 0.45 : 0.35;
}
return isHighlighted ? 0.85 : 0.08;
}
function edgeWidth(
edge: TimelineEdge,
isHighlighted: boolean,
hasSelection: boolean,
): number {
if (hasSelection && isHighlighted) return 2;
if (edge.kind === "failure" || edge.kind === "timeout") return 1.5;
return 1.2;
}
/** Dash array for failure/timeout edges */
function edgeDash(kind: EdgeKind): string | undefined {
if (kind === "failure") return "4 3";
if (kind === "timeout") return "6 3 2 3";
return undefined;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function TimelineRenderer({
layout,
tasks,
config: configOverride,
onTaskClick,
idPrefix = "",
}: TimelineRendererProps) {
const config = useMemo<LayoutConfig>(
() => ({ ...DEFAULT_LAYOUT, ...configOverride }),
[configOverride],
);
const containerRef = useRef<HTMLDivElement>(null);
// Track container width in state so the tooltip can use it without
// reading a ref during render.
const [containerWidth, setContainerWidth] = useState(800);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0) {
setContainerWidth(entry.contentRect.width);
}
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// ---- Interaction state ----
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// ---- Node lookup ----
const nodeMap = useMemo(() => {
const map = new Map<string, TimelineNode>();
for (const n of layout.nodes) {
map.set(n.id, n);
}
return map;
}, [layout.nodes]);
// ---- Highlighted path ----
const highlighted = useMemo(() => {
if (!selectedTaskId) return null;
return findConnectedPath(selectedTaskId, tasks, layout.edges);
}, [selectedTaskId, tasks, layout.edges]);
// ---- Grid lines ----
const gridLines = useMemo<GridLine[]>(() => {
// Reconstruct the time scale from layout bounds
const axisWidth =
layout.totalWidth - config.paddingLeft - config.paddingRight;
const pxPerMs =
axisWidth / Math.max(layout.maxTimeMs - layout.minTimeMs, 1);
return computeGridLines(
{
minMs: layout.minTimeMs,
maxMs: layout.maxTimeMs,
axisWidth,
pxPerMs,
},
config,
);
}, [layout.totalWidth, layout.minTimeMs, layout.maxTimeMs, config]);
// ---- Computed SVG dimensions ----
const svgWidth = layout.totalWidth;
const svgHeight = layout.totalHeight;
// ---- Edge paths (memoised) ----
const edgePaths = useMemo(() => {
return layout.edges
.map((edge) => {
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) return null;
const path = computeEdgePath(fromNode, toNode, config);
return { edge, path, fromNode, toNode };
})
.filter((p): p is NonNullable<typeof p> => p != null);
}, [layout.edges, nodeMap, config]);
// ---- Handlers ----
const handleTaskHover = useCallback(
(task: TimelineTask, e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({
task,
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
},
[],
);
const handleTaskLeave = useCallback(() => {
setTooltip(null);
}, []);
const handleTaskClick = useCallback(
(task: TimelineTask, e: React.MouseEvent) => {
e.stopPropagation();
if (selectedTaskId === task.id) {
setSelectedTaskId(null); // deselect
} else {
setSelectedTaskId(task.id);
}
},
[selectedTaskId],
);
const handleBackgroundClick = useCallback(() => {
setSelectedTaskId(null);
}, []);
// ---- Determine if anything is selected ----
const hasSelection = selectedTaskId != null;
// ---- Render ----
return (
<div className="relative select-none">
{/* Hint */}
<div className="absolute bottom-1 right-2 z-10 text-[10px] text-gray-400 pointer-events-none">
Click task to highlight path · Double-click to view details
</div>
{/* Scrollable container */}
<div ref={containerRef} className="overflow-x-auto overflow-y-hidden">
<svg
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${layout.totalWidth} ${layout.totalHeight}`}
className="block"
style={{
width: svgWidth,
height: svgHeight,
}}
onClick={handleBackgroundClick}
>
{/* ---- Definitions ---- */}
<defs>
{/* Subtle drop shadow for task bars */}
<filter
id={`${idPrefix}barShadow`}
x="-2%"
y="-10%"
width="104%"
height="130%"
>
<feDropShadow
dx="0"
dy="1"
stdDeviation="1.5"
floodOpacity="0.08"
/>
</filter>
{/* Arrowhead markers for each edge kind */}
{(
[
"success",
"failure",
"always",
"timeout",
"custom",
] as EdgeKind[]
).map((kind) => (
<marker
key={kind}
id={`${idPrefix}arrow-${kind}`}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path
d="M 0 1 L 10 5 L 0 9 z"
fill={EDGE_KIND_COLORS[kind]}
opacity="0.6"
/>
</marker>
))}
{/* Custom-colored arrow for edges with explicit color */}
<marker
id={`${idPrefix}arrow-custom-color`}
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path
d="M 0 1 L 10 5 L 0 9 z"
fill="currentColor"
opacity="0.6"
/>
</marker>
</defs>
{/* ---- Background ---- */}
<rect
width={layout.totalWidth}
height={layout.totalHeight}
fill="#fafbfc"
rx="0"
/>
{/* ---- Lane stripes (alternating very subtle shading) ---- */}
{Array.from({ length: layout.laneCount }, (_, i) => (
<rect
key={`lane-${i}`}
x={0}
y={config.paddingTop + i * config.laneHeight}
width={layout.totalWidth}
height={config.laneHeight}
fill={i % 2 === 0 ? "transparent" : "rgba(0,0,0,0.015)"}
/>
))}
{/* ---- Grid lines ---- */}
{gridLines.map((gl, i) => (
<g key={`grid-${i}`}>
<line
x1={gl.x}
y1={config.paddingTop}
x2={gl.x}
y2={layout.totalHeight}
stroke={gl.major ? "#e5e7eb" : "#f3f4f6"}
strokeWidth={gl.major ? 1 : 0.5}
strokeDasharray={gl.major ? undefined : "2 4"}
/>
{gl.major && gl.label && (
<text
x={gl.x}
y={config.paddingTop - 8}
textAnchor="middle"
fill="#9ca3af"
fontSize="10"
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
>
{gl.label}
</text>
)}
</g>
))}
{/* ---- Edges (rendered behind nodes) ---- */}
<g className="edges">
{edgePaths.map(({ edge, path }, i) => {
const key = edgeKey(edge.from, edge.to);
const isHl = highlighted?.edgeKeys.has(key) ?? false;
const color = edgeColor(edge);
const opacity = edgeOpacity(edge, isHl, hasSelection);
const width = edgeWidth(edge, isHl, hasSelection);
const dash = edgeDash(edge.kind);
return (
<g key={`edge-${i}`}>
<path
d={path}
fill="none"
stroke={color}
strokeWidth={width}
strokeOpacity={opacity}
strokeDasharray={dash}
markerEnd={
edge.color
? undefined // custom-color arrows are hard via markers; skip
: `url(#${idPrefix}arrow-${edge.kind})`
}
style={{
transition:
"stroke-opacity 0.2s ease, stroke-width 0.15s ease",
}}
/>
{/* Edge label */}
{edge.label && (
<EdgeLabel
path={path}
label={edge.label}
color={color}
opacity={hasSelection ? (isHl ? 0.9 : 0.1) : 0.6}
/>
)}
</g>
);
})}
</g>
{/* ---- Milestone nodes ---- */}
{layout.nodes
.filter((n) => n.type === "milestone" && n.milestone)
.map((node) => {
const ms = node.milestone!;
const size = config.milestoneSize;
const color = MILESTONE_COLORS[ms.kind];
const isHl = highlighted?.nodeIds.has(node.id) ?? false;
const nodeOpacity = hasSelection ? (isHl ? 1 : 0.15) : 1;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
opacity={nodeOpacity}
style={{ transition: "opacity 0.2s ease" }}
>
{/* Diamond shape */}
<rect
x={-size / 2}
y={-size / 2}
width={size}
height={size}
rx={2}
fill={color}
transform="rotate(45)"
stroke="white"
strokeWidth={1.5}
/>
{/* Label */}
{ms.label && (
<text
y={size / 2 + 12}
textAnchor="middle"
fill="#6b7280"
fontSize="9"
fontWeight="500"
>
{ms.label}
</text>
)}
</g>
);
})}
{/* ---- Task bars ---- */}
{layout.nodes
.filter((n) => n.type === "task" && n.task)
.map((node) => {
const task = node.task!;
const gi = task.groupInfo;
const colors = STATE_COLORS[task.state];
const isSelected = selectedTaskId === task.id;
const isHl = highlighted?.nodeIds.has(node.id) ?? false;
const nodeOpacity = hasSelection ? (isHl ? 1 : 0.18) : 1;
const barRadius = 4;
// Running tasks (or groups with running items) get a subtle pulse
const isRunning = task.state === "running";
return (
<g
key={node.id}
className="cursor-pointer"
opacity={nodeOpacity}
style={{ transition: "opacity 0.2s ease" }}
onMouseEnter={(e) => handleTaskHover(task, e)}
onMouseMove={(e) => handleTaskHover(task, e)}
onMouseLeave={handleTaskLeave}
onClick={(e) => handleTaskClick(task, e)}
onDoubleClick={(e) => {
e.stopPropagation();
onTaskClick?.(task);
}}
>
{/* Selection ring */}
{isSelected && (
<rect
x={node.x - 2}
y={node.y - 2}
width={node.width + 4}
height={config.barHeight + 4}
rx={barRadius + 1}
fill="none"
stroke={colors.border}
strokeWidth={2}
opacity={0.6}
/>
)}
{/* Bar background */}
<rect
x={node.x}
y={node.y}
width={node.width}
height={config.barHeight}
rx={barRadius}
fill={colors.bg}
stroke={colors.border}
strokeWidth={isSelected ? 1.5 : 1}
strokeDasharray={gi ? "3 2" : undefined}
filter={`url(#${idPrefix}barShadow)`}
>
{isRunning && (
<animate
attributeName="opacity"
values="1;0.75;1"
dur="2s"
repeatCount="indefinite"
/>
)}
</rect>
{/* Group progress segments — stacked colored bar showing
completed / running / failed / pending proportions */}
{gi && gi.totalItems > 0 && (
<GroupProgressBar
x={node.x}
y={node.y}
width={node.width}
height={config.barHeight}
radius={barRadius}
groupInfo={gi}
isRunning={isRunning}
/>
)}
{/* Progress fill for running tasks (non-group only) */}
{!gi && isRunning && task.startMs != null && (
<rect
x={node.x}
y={node.y}
width={node.width}
height={config.barHeight}
rx={barRadius}
fill={colors.border}
opacity={0.12}
>
<animate
attributeName="opacity"
values="0.06;0.15;0.06"
dur="2s"
repeatCount="indefinite"
/>
</rect>
)}
{/* Left accent bar for state */}
<rect
x={node.x}
y={node.y}
width={3}
height={config.barHeight}
rx={barRadius}
fill={colors.border}
/>
{/* Task name label */}
<clipPath id={`${idPrefix}clip-${node.id}`}>
<rect
x={node.x + 6}
y={node.y}
width={Math.max(0, node.width - 12)}
height={config.barHeight}
/>
</clipPath>
<text
x={node.x + 8}
y={node.y + config.barHeight / 2}
dominantBaseline="central"
fill={colors.text}
fontSize="11"
fontWeight="500"
fontFamily="ui-sans-serif, system-ui, -apple-system, sans-serif"
clipPath={`url(#${idPrefix}clip-${node.id})`}
>
{task.name}
{task.taskIndex != null ? ` [${task.taskIndex}]` : ""}
{gi && (
<tspan
fill={colors.border}
fontSize="10"
fontWeight="600"
>
{" — "}
{gi.completed + gi.failed + gi.timedOut + gi.cancelled}
{" of "}
{gi.totalItems}
{" completed"}
</tspan>
)}
</text>
{/* Timed-out indicator */}
{task.timedOut && (
<g
transform={`translate(${node.x + node.width - 14}, ${node.y + 3})`}
>
<circle
r="5"
fill="#f97316"
opacity="0.9"
cx="5"
cy="5"
/>
<text
x="5"
y="5.5"
textAnchor="middle"
dominantBaseline="central"
fill="white"
fontSize="7"
fontWeight="bold"
>
!
</text>
</g>
)}
{/* Terminal indicator — right-side end-cap for leaf tasks */}
{task.downstreamIds.length === 0 &&
task.state !== "running" &&
task.state !== "pending" && (
<rect
x={node.x + node.width - 3}
y={node.y}
width={3}
height={config.barHeight}
rx={1}
fill={colors.border}
opacity={0.6}
/>
)}
</g>
);
})}
</svg>
</div>
{/* ---- Tooltip ---- */}
{tooltip && (
<TaskTooltip tooltip={tooltip} containerWidth={containerWidth} />
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Edge label sub-component
// ---------------------------------------------------------------------------
function EdgeLabel({
path,
label,
color,
opacity,
}: {
path: string;
label: string;
color: string;
opacity: number;
}) {
// Parse the path to approximate the midpoint of a cubic Bezier
// M x0 y0 C cx1 cy1, cx2 cy2, x1 y1
const mid = useMemo(() => {
const nums = path.match(/-?[\d.]+/g)?.map(Number);
if (!nums || nums.length < 8) return null;
const [x0, y0, cx1, cy1, cx2, cy2, x1, y1] = nums;
// Cubic Bezier at t=0.5
const t = 0.45; // slightly before midpoint to avoid overlap with arrowhead
const mt = 1 - t;
const mx =
mt * mt * mt * x0 +
3 * mt * mt * t * cx1 +
3 * mt * t * t * cx2 +
t * t * t * x1;
const my =
mt * mt * mt * y0 +
3 * mt * mt * t * cy1 +
3 * mt * t * t * cy2 +
t * t * t * y1;
return { x: mx, y: my };
}, [path]);
if (!mid) return null;
return (
<g opacity={opacity} style={{ transition: "opacity 0.2s ease" }}>
{/* Background pill */}
<rect
x={mid.x - label.length * 2.8 - 4}
y={mid.y - 7}
width={label.length * 5.6 + 8}
height={14}
rx={3}
fill="white"
stroke={color}
strokeWidth={0.5}
opacity={0.92}
/>
<text
x={mid.x}
y={mid.y}
textAnchor="middle"
dominantBaseline="central"
fill={color}
fontSize="9"
fontWeight="600"
fontFamily="ui-sans-serif, system-ui, sans-serif"
>
{label}
</text>
</g>
);
}
// ---------------------------------------------------------------------------
// Tooltip sub-component
// ---------------------------------------------------------------------------
function TaskTooltip({
tooltip,
containerWidth: cw,
}: {
tooltip: TooltipData;
containerWidth: number;
}) {
const { task, x, y } = tooltip;
const gi = task.groupInfo;
// Position the tooltip to avoid clipping at the edges
const tooltipWidth = 280;
const left = x + tooltipWidth + 16 > cw ? x - tooltipWidth - 8 : x + 16;
const top = Math.max(8, y - 10);
return (
<div className="absolute z-30 pointer-events-none" style={{ left, top }}>
<div
className="bg-gray-900 text-white rounded-lg shadow-xl px-3.5 py-3 text-xs"
style={{ width: tooltipWidth }}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<span
className="inline-block w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: STATE_COLORS[task.state].border }}
/>
<span className="font-semibold text-sm truncate">{task.name}</span>
{task.taskIndex != null && (
<span className="text-gray-400 text-[10px]">
[{task.taskIndex}]
</span>
)}
</div>
{/* Group progress summary (collapsed with_items) */}
{gi && (
<div className="mb-2 space-y-1.5">
{/* Stacked progress bar */}
<div className="flex h-2 rounded-full overflow-hidden bg-gray-700">
{gi.completed > 0 && (
<div
className="bg-green-500"
style={{
width: `${(gi.completed / gi.totalItems) * 100}%`,
}}
/>
)}
{gi.running > 0 && (
<div
className="bg-blue-500 animate-pulse"
style={{
width: `${(gi.running / gi.totalItems) * 100}%`,
}}
/>
)}
{gi.failed > 0 && (
<div
className="bg-red-500"
style={{
width: `${(gi.failed / gi.totalItems) * 100}%`,
}}
/>
)}
{gi.timedOut > 0 && (
<div
className="bg-orange-500"
style={{
width: `${(gi.timedOut / gi.totalItems) * 100}%`,
}}
/>
)}
</div>
{/* Counts row */}
<div className="flex gap-2 text-[10px] text-gray-400 flex-wrap">
{gi.completed > 0 && (
<span className="text-green-400"> {gi.completed}</span>
)}
{gi.running > 0 && (
<span className="text-blue-400"> {gi.running}</span>
)}
{gi.pending > 0 && (
<span className="text-gray-400"> {gi.pending}</span>
)}
{gi.failed > 0 && (
<span className="text-red-400"> {gi.failed}</span>
)}
{gi.timedOut > 0 && (
<span className="text-orange-400"> {gi.timedOut}</span>
)}
{gi.cancelled > 0 && (
<span className="text-gray-500"> {gi.cancelled}</span>
)}
<span className="text-gray-500 ml-auto">
of {gi.totalItems} items
</span>
</div>
{gi.concurrency > 0 && (
<Row label="Concurrency" value={`${gi.concurrency}`} />
)}
</div>
)}
{/* Details grid */}
<div className="space-y-1 text-gray-300">
<Row label="State" value={stateLabel(task.state)} />
<Row label="Action" value={task.actionRef} />
{task.startMs != null && (
<Row label="Started" value={formatTime(task.startMs)} />
)}
{task.endMs != null && (
<Row label="Ended" value={formatTime(task.endMs)} />
)}
{task.durationMs != null && task.durationMs > 0 && (
<Row label="Duration" value={formatDuration(task.durationMs)} />
)}
{!gi && task.timedOut && (
<Row label="Timed Out" value="Yes" valueClass="text-orange-400" />
)}
{!gi && task.maxRetries > 0 && (
<Row
label="Retries"
value={`${task.retryCount} / ${task.maxRetries}`}
/>
)}
{task.upstreamIds.length > 0 && (
<Row
label="Upstream"
value={`${task.upstreamIds.length} task${task.upstreamIds.length !== 1 ? "s" : ""}`}
/>
)}
{task.downstreamIds.length > 0 && (
<Row
label="Downstream"
value={`${task.downstreamIds.length} task${task.downstreamIds.length !== 1 ? "s" : ""}`}
/>
)}
{task.downstreamIds.length === 0 &&
task.state !== "running" &&
task.state !== "pending" && (
<Row
label="Terminal"
value="No further tasks"
valueClass="text-gray-500 italic"
/>
)}
</div>
{/* Footer hint */}
<div className="mt-2 pt-1.5 border-t border-gray-700 text-[10px] text-gray-500">
{gi
? "Click to highlight path"
: "Click to highlight path · Double-click to view details"}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// GroupProgressBar — stacked segment fill for collapsed with_items nodes
// ---------------------------------------------------------------------------
function GroupProgressBar({
x,
y,
width,
height,
radius,
groupInfo: gi,
isRunning,
}: {
x: number;
y: number;
width: number;
height: number;
radius: number;
groupInfo: WithItemsGroupInfo;
isRunning: boolean;
}) {
const total = gi.totalItems;
if (total === 0) return null;
// Compute segment widths as proportions of the bar
const innerWidth = width;
const segments: { color: string; w: number; animate?: boolean }[] = [];
if (gi.completed > 0)
segments.push({
color: STATE_COLORS.completed.border,
w: (gi.completed / total) * innerWidth,
});
if (gi.running > 0)
segments.push({
color: STATE_COLORS.running.border,
w: (gi.running / total) * innerWidth,
animate: true,
});
if (gi.failed > 0)
segments.push({
color: STATE_COLORS.failed.border,
w: (gi.failed / total) * innerWidth,
});
if (gi.timedOut > 0)
segments.push({
color: "#f97316",
w: (gi.timedOut / total) * innerWidth,
});
if (gi.cancelled > 0)
segments.push({
color: "#9ca3af",
w: (gi.cancelled / total) * innerWidth,
});
let offsetX = 0;
return (
<g>
{/* Clip path to keep segments within the rounded bar */}
<clipPath id={`group-progress-${gi.memberIds[0]}`}>
<rect x={x} y={y} width={width} height={height} rx={radius} />
</clipPath>
<g clipPath={`url(#group-progress-${gi.memberIds[0]})`}>
{segments.map((seg, i) => {
const sx = x + offsetX;
offsetX += seg.w;
return (
<rect
key={i}
x={sx}
y={y}
width={Math.max(seg.w, 1)}
height={height}
fill={seg.color}
opacity={0.25}
>
{seg.animate && (
<animate
attributeName="opacity"
values="0.15;0.35;0.15"
dur="2s"
repeatCount="indefinite"
/>
)}
</rect>
);
})}
</g>
{/* Thin progress track at the bottom of the bar */}
<g clipPath={`url(#group-progress-${gi.memberIds[0]})`}>
{(() => {
let bx = 0;
return segments.map((seg, i) => {
const sx = x + bx;
bx += seg.w;
return (
<rect
key={`b${i}`}
x={sx}
y={y + height - 3}
width={Math.max(seg.w, 1)}
height={3}
fill={seg.color}
opacity={0.7}
>
{seg.animate && (
<animate
attributeName="opacity"
values="0.5;0.9;0.5"
dur="2s"
repeatCount="indefinite"
/>
)}
</rect>
);
});
})()}
</g>
{/* Subtle overall pulse when still running */}
{isRunning && (
<rect
x={x}
y={y}
width={width}
height={height}
rx={radius}
fill={STATE_COLORS.running.border}
opacity={0.06}
>
<animate
attributeName="opacity"
values="0.03;0.08;0.03"
dur="2s"
repeatCount="indefinite"
/>
</rect>
)}
</g>
);
}
// ---------------------------------------------------------------------------
// Row / TaskTooltip sub-components
// ---------------------------------------------------------------------------
function Row({
label,
value,
valueClass,
}: {
label: string;
value: string;
valueClass?: string;
}) {
return (
<div className="flex justify-between gap-3">
<span className="text-gray-400 flex-shrink-0">{label}</span>
<span className={`truncate text-right ${valueClass ?? "text-gray-200"}`}>
{value}
</span>
</div>
);
}