/** * 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( () => ({ ...DEFAULT_LAYOUT, ...configOverride }), [configOverride], ); const containerRef = useRef(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(null); const [selectedTaskId, setSelectedTaskId] = useState(null); // ---- Node lookup ---- const nodeMap = useMemo(() => { const map = new Map(); 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(() => { // 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 => 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 (
{/* Hint */}
Click task to highlight path · Double-click to view details
{/* Scrollable container */}
{/* ---- Definitions ---- */} {/* Subtle drop shadow for task bars */} {/* Arrowhead markers for each edge kind */} {( [ "success", "failure", "always", "timeout", "custom", ] as EdgeKind[] ).map((kind) => ( ))} {/* Custom-colored arrow for edges with explicit color */} {/* ---- Background ---- */} {/* ---- Lane stripes (alternating very subtle shading) ---- */} {Array.from({ length: layout.laneCount }, (_, i) => ( ))} {/* ---- Grid lines ---- */} {gridLines.map((gl, i) => ( {gl.major && gl.label && ( {gl.label} )} ))} {/* ---- Edges (rendered behind nodes) ---- */} {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 ( {/* Edge label */} {edge.label && ( )} ); })} {/* ---- 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 ( {/* Diamond shape */} {/* Label */} {ms.label && ( {ms.label} )} ); })} {/* ---- 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 ( handleTaskHover(task, e)} onMouseMove={(e) => handleTaskHover(task, e)} onMouseLeave={handleTaskLeave} onClick={(e) => handleTaskClick(task, e)} onDoubleClick={(e) => { e.stopPropagation(); onTaskClick?.(task); }} > {/* Selection ring */} {isSelected && ( )} {/* Bar background */} {isRunning && ( )} {/* Group progress segments — stacked colored bar showing completed / running / failed / pending proportions */} {gi && gi.totalItems > 0 && ( )} {/* Progress fill for running tasks (non-group only) */} {!gi && isRunning && task.startMs != null && ( )} {/* Left accent bar for state */} {/* Task name label */} {task.name} {task.taskIndex != null ? ` [${task.taskIndex}]` : ""} {gi && ( {" — "} {gi.completed + gi.failed + gi.timedOut + gi.cancelled} {" of "} {gi.totalItems} {" completed"} )} {/* Timed-out indicator */} {task.timedOut && ( ! )} {/* Terminal indicator — right-side end-cap for leaf tasks */} {task.downstreamIds.length === 0 && task.state !== "running" && task.state !== "pending" && ( )} ); })}
{/* ---- Tooltip ---- */} {tooltip && ( )}
); } // --------------------------------------------------------------------------- // 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 ( {/* Background pill */} {label} ); } // --------------------------------------------------------------------------- // 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 (
{/* Header */}
{task.name} {task.taskIndex != null && ( [{task.taskIndex}] )}
{/* Group progress summary (collapsed with_items) */} {gi && (
{/* Stacked progress bar */}
{gi.completed > 0 && (
)} {gi.running > 0 && (
)} {gi.failed > 0 && (
)} {gi.timedOut > 0 && (
)}
{/* Counts row */}
{gi.completed > 0 && ( ✓ {gi.completed} )} {gi.running > 0 && ( ⟳ {gi.running} )} {gi.pending > 0 && ( ○ {gi.pending} )} {gi.failed > 0 && ( ✗ {gi.failed} )} {gi.timedOut > 0 && ( ⏱ {gi.timedOut} )} {gi.cancelled > 0 && ( ⊘ {gi.cancelled} )} of {gi.totalItems} items
{gi.concurrency > 0 && ( )}
)} {/* Details grid */}
{task.startMs != null && ( )} {task.endMs != null && ( )} {task.durationMs != null && task.durationMs > 0 && ( )} {!gi && task.timedOut && ( )} {!gi && task.maxRetries > 0 && ( )} {task.upstreamIds.length > 0 && ( )} {task.downstreamIds.length > 0 && ( )} {task.downstreamIds.length === 0 && task.state !== "running" && task.state !== "pending" && ( )}
{/* Footer hint */}
{gi ? "Click to highlight path" : "Click to highlight path · Double-click to view details"}
); } // --------------------------------------------------------------------------- // 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 ( {/* Clip path to keep segments within the rounded bar */} {segments.map((seg, i) => { const sx = x + offsetX; offsetX += seg.w; return ( {seg.animate && ( )} ); })} {/* Thin progress track at the bottom of the bar */} {(() => { let bx = 0; return segments.map((seg, i) => { const sx = x + bx; bx += seg.w; return ( {seg.animate && ( )} ); }); })()} {/* Subtle overall pulse when still running */} {isRunning && ( )} ); } // --------------------------------------------------------------------------- // Row / TaskTooltip sub-components // --------------------------------------------------------------------------- function Row({ label, value, valueClass, }: { label: string; value: string; valueClass?: string; }) { return (
{label} {value}
); }