// Note: deno fmt can crash on deeply nested ternaries in html/svg templates - see renderBaselineStats import { css, html, LitElement, svg as svgTag, TemplateResult } from "lit"; import { property, query, state } from "lit/decorators.js"; import dagre from "dagre"; import type { DebuggerController } from "../lib/debugger-controller.ts"; import type { SchedulerGraphNode } from "@commontools/runner"; interface LayoutNode { id: string; label: string; fullId: string; // Full ID for tooltip type: "effect" | "computation" | "input"; x: number; y: number; width: number; height: number; stats?: SchedulerGraphNode["stats"]; isDirty: boolean; isPending: boolean; parentId?: string; childCount?: number; collapsedChildCount?: number; // Number of hidden children when collapsed preview?: string; // Function body preview for hover tooltip reads?: string[]; // Diagnostic: cell paths this action reads writes?: string[]; // Diagnostic: cell paths this action writes debounceMs?: number; // Current debounce delay in ms throttleMs?: number; // Current throttle period in ms } interface LayoutEdge { id: string; from: string; to: string; cells: string[]; isHistorical: boolean; edgeType?: "data" | "parent"; } const NODE_WIDTH = 140; const NODE_HEIGHT = 36; /** * Scheduler Graph visualization component. * Shows dependency graph with effects and computations. */ export class XSchedulerGraph extends LitElement { static override styles = css` :host { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .toolbar { display: flex; gap: 0.5rem; padding: 0.5rem 1rem; background: #1e293b; border-bottom: 1px solid #334155; align-items: center; flex-shrink: 0; } .toggle-group { display: flex; gap: 0; } .toggle-button { padding: 0.375rem 0.75rem; background: #334155; border: 1px solid #475569; color: #94a3b8; font-size: 0.75rem; font-family: monospace; cursor: pointer; transition: all 0.2s; } .toggle-button:first-child { border-radius: 0.375rem 0 0 0.375rem; } .toggle-button:last-child { border-radius: 0 0.375rem 0.375rem 0; border-left: none; } .toggle-button:hover { background: #475569; color: white; } .toggle-button.active { background: #3b82f6; border-color: #3b82f6; color: white; } .action-button { padding: 0.375rem 0.75rem; background: #334155; border: 1px solid #475569; border-radius: 0.375rem; color: #94a3b8; font-size: 0.75rem; font-family: monospace; cursor: pointer; transition: all 0.2s; } .action-button:hover { background: #475569; color: white; } .zoom-controls { display: flex; gap: 0; } .zoom-button { width: 28px; height: 28px; padding: 0; background: #334155; border: 1px solid #475569; color: #94a3b8; font-size: 1rem; font-family: monospace; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; } .zoom-button:first-child { border-radius: 0.375rem 0 0 0.375rem; } .zoom-button:last-child { border-radius: 0 0.375rem 0.375rem 0; border-left: none; } .zoom-button:hover { background: #475569; color: white; } .zoom-level { padding: 0.375rem 0.5rem; background: #1e293b; border: 1px solid #475569; border-left: none; color: #cbd5e1; font-size: 0.6875rem; font-family: monospace; cursor: pointer; min-width: 48px; text-align: center; } .zoom-level:hover { background: #334155; } .stats { margin-left: auto; display: flex; gap: 1rem; color: #94a3b8; font-size: 0.6875rem; font-family: monospace; } .stat-value { color: #cbd5e1; } .graph-wrapper { flex: 1; display: flex; overflow: hidden; position: relative; } .graph-container { flex: 1; overflow: auto; position: relative; background: #0f172a; } .graph-svg { display: block; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: #64748b; font-size: 0.875rem; gap: 1rem; } /* Node styles */ .node-group { cursor: pointer; } .node-rect { stroke: #475569; stroke-width: 1; rx: 4; transition: filter 0.3s ease-out; } .node-effect .node-rect { fill: #1e40af; /* blue-800 */ } .node-computation .node-rect { fill: #5b21b6; /* violet-800 */ } .node-input .node-rect { fill: #065f46; /* emerald-800 */ } .node-inactive .node-rect { fill: #374151; /* gray-700 */ opacity: 0.7; } .node-inactive .node-label { fill: #9ca3af; /* gray-400 */ font-style: italic; } .node-dirty .node-rect { fill: #78350f; /* amber-900 */ } .node-pending .node-rect { stroke: #fbbf24; stroke-width: 2; } .node-label { fill: white; font-size: 10px; font-family: monospace; pointer-events: none; } .node-type-badge { fill: rgba(255, 255, 255, 0.3); font-size: 8px; font-family: monospace; pointer-events: none; } .node-stats { fill: rgba(255, 255, 255, 0.6); font-size: 8px; font-family: monospace; pointer-events: none; } .node-collapse-toggle { fill: rgba(255, 255, 255, 0.5); font-size: 12px; font-family: monospace; cursor: pointer; transition: fill 0.2s; } .node-collapse-toggle:hover { fill: white; } .node-child-count { fill: #f59e0b; font-size: 8px; font-family: monospace; pointer-events: none; } /* Parent group container styles */ .parent-group-rect { fill: rgba(255, 255, 255, 0.03); stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; stroke-dasharray: 4 2; rx: 8; } .parent-group-label { fill: rgba(255, 255, 255, 0.3); font-size: 9px; font-family: monospace; pointer-events: none; } /* Edge styles */ .edge-current { stroke: #64748b; stroke-width: 1.5; fill: none; } .edge-historical { stroke: #475569; stroke-width: 1; stroke-dasharray: 4 2; fill: none; } .edge-path { cursor: pointer; transition: stroke 0.2s; } .edge-path:hover { stroke: #f59e0b; stroke-width: 2; } .arrow-marker { fill: #64748b; } .edge-parent { stroke-dasharray: 4 3; stroke: #94a3b8; } .edge-parent:hover { stroke: #cbd5e1; } /* Legend */ .legend { position: absolute; bottom: 0.5rem; left: 0.5rem; background: rgba(30, 41, 59, 0.9); border: 1px solid #475569; border-radius: 0.375rem; padding: 0.5rem 0.75rem; font-size: 0.6875rem; color: #cbd5e1; font-family: monospace; display: flex; gap: 1rem; } .legend-item { display: flex; align-items: center; gap: 0.375rem; } .legend-swatch { width: 12px; height: 12px; border-radius: 2px; } .legend-swatch.input { background: #065f46; } .legend-swatch.computation { background: #5b21b6; } .legend-swatch.effect { background: #1e40af; } .legend-line { width: 20px; height: 2px; background: #64748b; } .legend-line.parent { background: repeating-linear-gradient( 90deg, #94a3b8 0px, #94a3b8 4px, transparent 4px, transparent 7px ); } /* Tooltip */ .tooltip { position: absolute; background: #1e293b; border: 1px solid #475569; border-radius: 0.375rem; padding: 0.5rem; font-size: 0.6875rem; color: #cbd5e1; max-width: 300px; z-index: 100; pointer-events: none; font-family: monospace; } .tooltip-title { font-weight: 600; margin-bottom: 0.25rem; color: #e2e8f0; } .tooltip-cells { color: #94a3b8; } .tooltip-cell { padding: 0.125rem 0; word-break: break-all; } /* Glow animation */ @keyframes node-glow { 0% { filter: drop-shadow(0 0 8px currentColor); } 100% { filter: drop-shadow(0 0 0 transparent); } } .node-triggered .node-rect { animation: node-glow 2s ease-out; } .node-effect.node-triggered { color: #3b82f6; } .node-computation.node-triggered { color: #8b5cf6; } .node-selected .node-rect { stroke: #f59e0b; stroke-width: 3; } /* Size boost animation for triggered nodes when zoomed out */ @keyframes node-size-boost { 0%, 100% { transform: scale(1); } 15% { transform: scale(2.5); } 85% { transform: scale(2.5); } } .node-boosted { transform-origin: center center; animation: node-size-boost 2s ease-out; } /* Table view styles */ .table-container { flex: 1; overflow: auto; background: #0f172a; } .stats-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; font-family: monospace; } .stats-table th { background: #1e293b; color: #94a3b8; font-weight: 500; text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; position: sticky; top: 0; cursor: pointer; user-select: none; } .stats-table th:hover { background: #334155; color: #e2e8f0; } .stats-table th.sorted { color: #3b82f6; } .stats-table th .sort-indicator { margin-left: 0.25rem; opacity: 0.5; } .stats-table th.sorted .sort-indicator { opacity: 1; } .stats-table td { padding: 0.375rem 0.75rem; border-bottom: 1px solid #1e293b; color: #cbd5e1; } .stats-table tr:hover td { background: #1e293b; } .stats-table .col-name { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .stats-table .col-type { width: 80px; } .stats-table .col-number { width: 100px; text-align: right; font-variant-numeric: tabular-nums; } .type-badge { display: inline-block; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.625rem; font-weight: 500; } .type-badge.effect { background: #1e40af; color: #93c5fd; } .type-badge.computation { background: #5b21b6; color: #c4b5fd; } .type-badge.input { background: #065f46; color: #6ee7b7; } .type-badge.inactive { background: #374151; color: #9ca3af; font-style: italic; } /* Timing control badges */ .timing-badge { display: inline-block; padding: 0.0625rem 0.25rem; border-radius: 0.1875rem; font-size: 0.5625rem; font-weight: 500; margin-left: 0.375rem; vertical-align: middle; } .timing-badge.debounce { background: #7c3aed; color: #ddd6fe; } .timing-badge.throttle { background: #0891b2; color: #cffafe; } /* Table wrapper for detail pane layout */ .table-wrapper { display: flex; flex: 1; overflow: hidden; } .table-wrapper .table-container { flex: 1; overflow: auto; } .table-wrapper .detail-pane { width: 350px; flex-shrink: 0; border-left: 1px solid #334155; } /* Expand/collapse toggle button */ .expand-toggle { background: none; border: none; color: #64748b; cursor: pointer; padding: 0; width: 1.25rem; font-size: 0.625rem; text-align: center; transition: color 0.15s; } .expand-toggle:hover { color: #94a3b8; } /* Parent row styling */ .stats-table tr.parent-row { background: #1a2744; } .stats-table tr.parent-row td { border-bottom-color: #334155; } /* Selected row styling */ .stats-table tr.selected td { background: #1e3a5f; } .stats-table tr.selected:hover td { background: #234768; } /* Child row styling */ .stats-table tr.child-row { background: #0c1322; } .stats-table tr.child-row td { border-bottom-color: #1e293b; } .child-indent { color: #475569; margin-right: 0.25rem; } /* Aggregated stats styling */ .aggregated { color: #94a3b8; font-style: italic; } .child-count { color: #64748b; font-size: 0.625rem; margin-left: 0.25rem; } .view-toggle { display: flex; gap: 0; margin-left: 0.5rem; } .detail-pane { width: 320px; flex-shrink: 0; background: #1e293b; border-left: 1px solid #475569; padding: 0.75rem; overflow-y: auto; font-size: 0.75rem; } .detail-pane-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid #475569; } .detail-pane-title { font-weight: 600; color: #f1f5f9; font-size: 0.875rem; } .detail-pane-close { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 1rem; padding: 0.25rem; } .detail-pane-close:hover { color: #f1f5f9; } .detail-section { margin-bottom: 0.75rem; } .detail-section-title { font-weight: 600; color: #94a3b8; font-size: 0.6875rem; text-transform: uppercase; margin-bottom: 0.25rem; } .detail-section-content { color: #cbd5e1; font-family: monospace; font-size: 0.6875rem; white-space: pre-wrap; word-break: break-all; } .detail-stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; } .detail-stat { background: #0f172a; padding: 0.5rem; border-radius: 0.25rem; } .detail-stat-label { color: #64748b; font-size: 0.625rem; text-transform: uppercase; } .detail-stat-value { color: #f1f5f9; font-size: 0.875rem; font-weight: 600; } .detail-preview { background: #0f172a; padding: 0.5rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.625rem; color: #94a3b8; white-space: pre-wrap; word-break: break-all; max-height: 150px; overflow-y: auto; } .detail-cell-list { display: flex; flex-direction: column; gap: 0.25rem; } .detail-cell { background: #0f172a; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-family: monospace; font-size: 0.625rem; color: #94a3b8; word-break: break-all; } .adjacent-nodes { display: flex; flex-direction: column; gap: 0.25rem; } .adjacent-node { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.5rem; background: #0f172a; border-radius: 0.25rem; cursor: pointer; transition: background 0.2s; } .adjacent-node:hover { background: #1e3a5f; } .adjacent-node .type-badge { flex-shrink: 0; } .adjacent-node-label { font-family: monospace; font-size: 0.6875rem; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* Delta styles for baseline comparison */ .delta { font-size: 0.625rem; margin-left: 0.25rem; } .delta.positive { color: #f59e0b; } .delta.zero { color: #64748b; } .stat-with-delta { display: flex; flex-direction: column; align-items: flex-end; } .stat-with-delta .stat-main { font-variant-numeric: tabular-nums; } .stat-with-delta .stat-delta { font-size: 0.625rem; color: #64748b; } .stat-with-delta .stat-delta.positive { color: #f59e0b; } .detail-stat-delta { font-size: 0.625rem; color: #64748b; margin-top: 0.125rem; } .detail-stat-delta.positive { color: #f59e0b; } .baseline-stats { border-left: 1px solid #475569; padding-left: 1rem; margin-left: 0.5rem; } .baseline-stats .stat-value { margin-left: 0.5rem; } .delta-sort-toggle { display: inline-flex; gap: 0; margin-left: 0.5rem; vertical-align: middle; } .delta-sort-toggle button { background: #334155; border: 1px solid #475569; color: #94a3b8; font-size: 0.5625rem; font-weight: 500; padding: 0.125rem 0.375rem; cursor: pointer; transition: all 0.2s; } .delta-sort-toggle button:first-child { border-radius: 0.25rem 0 0 0.25rem; } .delta-sort-toggle button:last-child { border-radius: 0 0.25rem 0.25rem 0; border-left: none; } .delta-sort-toggle button:hover { background: #475569; color: white; } .delta-sort-toggle button.active { background: #3b82f6; border-color: #3b82f6; color: white; } `; @property({ attribute: false }) debuggerController?: DebuggerController; @state() private layoutNodes = new Map(); @state() private layoutEdges: LayoutEdge[] = []; @state() private svgWidth = 800; @state() private svgHeight = 400; @state() private selectedEdge: LayoutEdge | null = null; @state() private selectedNode: LayoutNode | null = null; @state() private tooltipPosition = { x: 0, y: 0 }; @state() private triggeredNodes = new Map(); // id -> timestamp @state() private isPullMode = true; @state() private zoomLevel = 1.0; @state() private collapsedParents = new Set(); @state() private viewMode: "graph" | "table" = "table"; @state() private tableSortColumn: "totalTime" | "runCount" | "avgTime" | "lastTime" = "totalTime"; @state() private tableSortAscending = false; @state() private tableExpandedParents = new Set(); // When true, sort table by delta values instead of lifetime totals @state() private sortByDelta = false; @query(".graph-container") private graphContainer?: HTMLElement; private lastGraphVersion = -1; private hasInitialZoom = false; /** * Get baseline stats from the controller (persists across tab switches) */ private get baselineStats(): Map< string, { runCount: number; totalTime: number } > { return ( this.debuggerController?.getSchedulerBaselineStats() ?? new Map() ); } // Minimum effective node size before we boost triggered nodes private static readonly READABLE_THRESHOLD = 50; override connectedCallback() { super.connectedCallback(); this.updateLayout(); } override updated(changedProperties: Map) { super.updated(changedProperties); // Check if we need to update the graph if (this.debuggerController) { const currentVersion = this.debuggerController.getGraphUpdateVersion(); if (currentVersion !== this.lastGraphVersion) { this.lastGraphVersion = currentVersion; this.updateLayout(); // Zoom to fit on first load if (!this.hasInitialZoom && this.layoutNodes.size > 0) { this.hasInitialZoom = true; requestAnimationFrame(() => this.zoomToFit()); } } } } private updateLayout(): void { if (!this.debuggerController) return; const graphData = this.debuggerController.getGraphWithHistory(); if (!graphData) { this.layoutNodes = new Map(); this.layoutEdges = []; return; } this.isPullMode = graphData.pullMode; // Build a map of all nodes and identify which are hidden due to collapsed parents const nodeMap = new Map(graphData.nodes.map((n) => [n.id, n])); // Infer parent relationships for sinks without parents by matching entity IDs // This handles the case where sinks are created outside of action execution // but logically belong to a recipe/module action const inferredParents = this.inferParentsByEntity(graphData.nodes); // Create a combined view with both explicit and inferred parents const effectiveParentId = (node: SchedulerGraphNode): string | undefined => node.parentId || inferredParents.get(node.id); // Count children (explicit + inferred) const effectiveChildCounts = new Map(); for (const node of graphData.nodes) { const parent = effectiveParentId(node); if (parent) { effectiveChildCounts.set( parent, (effectiveChildCounts.get(parent) ?? 0) + 1, ); } } // Auto-collapse all parents with children (always add new ones) const newCollapsed = new Set(this.collapsedParents); let hasNewCollapsed = false; for (const node of graphData.nodes) { const childCount = effectiveChildCounts.get(node.id) ?? 0; if (childCount > 0 && !newCollapsed.has(node.id)) { newCollapsed.add(node.id); hasNewCollapsed = true; } } if (hasNewCollapsed) { this.collapsedParents = newCollapsed; } const hiddenNodes = new Set(); const collapsedChildCounts = new Map(); // Find all nodes that should be hidden (their parent is collapsed) for (const node of graphData.nodes) { const parent = effectiveParentId(node); if (parent && this.collapsedParents.has(parent)) { hiddenNodes.add(node.id); // Count hidden children for the collapsed parent collapsedChildCounts.set( parent, (collapsedChildCounts.get(parent) ?? 0) + 1, ); } } const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 70, marginx: 20, marginy: 20, }); g.setDefaultEdgeLabel(() => ({})); // Add nodes (excluding hidden ones) for (const node of graphData.nodes) { if (hiddenNodes.has(node.id)) continue; g.setNode(node.id, { label: this.truncateLabel(node.id), width: NODE_WIDTH, height: NODE_HEIGHT, type: node.type, stats: node.stats, isDirty: node.isDirty, isPending: node.isPending, parentId: effectiveParentId(node), childCount: effectiveChildCounts.get(node.id) ?? 0, }); } // Add edges (excluding edges to/from hidden nodes) for (const edge of graphData.edges) { if (hiddenNodes.has(edge.from) || hiddenNodes.has(edge.to)) continue; g.setEdge(edge.from, edge.to, { cells: edge.cells }); } // Run layout dagre.layout(g); // Extract positioned nodes const nodes = new Map(); for (const nodeId of g.nodes()) { const node = g.node(nodeId); if (node) { const originalNode = nodeMap.get(nodeId); const effParent = originalNode ? effectiveParentId(originalNode) : undefined; const effChildCount = effectiveChildCounts.get(nodeId) ?? 0; nodes.set(nodeId, { id: nodeId, label: node.label, fullId: nodeId, // Store full ID for tooltip type: node.type as "effect" | "computation", x: node.x, y: node.y, width: node.width, height: node.height, stats: originalNode?.stats, isDirty: originalNode?.isDirty ?? false, isPending: originalNode?.isPending ?? false, parentId: effParent, childCount: effChildCount > 0 ? effChildCount : undefined, collapsedChildCount: collapsedChildCounts.get(nodeId), preview: originalNode?.preview, reads: originalNode?.reads, writes: originalNode?.writes, debounceMs: originalNode?.debounceMs, throttleMs: originalNode?.throttleMs, }); } } // Extract positioned edges const edges: LayoutEdge[] = []; for (const e of g.edges()) { const edgeData = g.edge(e); const originalEdge = graphData.edges.find( (edge) => edge.from === e.v && edge.to === e.w, ); edges.push({ id: `${e.v}->${e.w}`, from: e.v, to: e.w, cells: edgeData?.cells ?? [], isHistorical: originalEdge?.isHistorical ?? false, edgeType: originalEdge?.edgeType, }); } // Calculate SVG dimensions const graphInfo = g.graph(); this.svgWidth = Math.max(800, (graphInfo.width ?? 0) + 40); this.svgHeight = Math.max(400, (graphInfo.height ?? 0) + 40); this.layoutNodes = nodes; this.layoutEdges = edges; } /** * Create a short, readable label from an action ID. * Format: prefix:...last4/path * * Examples: * - "sink:did:key:z6Mkk.../of:baedrei.../value" → "sink:...i.../value" * - "parentAction" → "parentAction" */ private truncateLabel(label: string, maxLen = 20): string { // Simple case - short enough already if (label.length <= maxLen) return label; // Try to parse structured IDs like "sink:did:key:.../of:baedrei.../path" // or "action:space/entity/path" // Check for sink: or other prefix const prefixMatch = label.match( /^(sink|action|handler|effect|computation):/i, ); const prefix = prefixMatch ? prefixMatch[1] + ":" : ""; const rest = prefix ? label.slice(prefix.length) : label; // Look for entity ID pattern (of:xxx or just the entity part after space/) // Common patterns: // - did:key:z6Mkk.../of:baedreide2e4l6ej534c3yimtw5g2bpfwve4xm6abycmfpk6oyml2dx4mme/path // - space/entityid/path // Try to find the last path segment(s) which are most meaningful const parts = rest.split("/"); if (parts.length >= 2) { // Get the entity ID (usually the second-to-last non-empty part before path) // and the path (last parts) let entityPart = ""; let pathParts: string[] = []; // Find entity ID - look for "of:" prefix or use second part for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (part.startsWith("of:")) { entityPart = part.slice(3); // Remove "of:" prefix pathParts = parts.slice(i + 1).filter((p) => p.length > 0); break; } } // If no "of:" found, try to identify entity from structure if (!entityPart && parts.length >= 2) { // Assume last non-empty parts are the path, entity is before that const nonEmpty = parts.filter((p) => p.length > 0); if (nonEmpty.length >= 2) { // Take last 4 chars of entity-like part const potentialEntity = nonEmpty.find((p) => p.length > 20) || nonEmpty[0]; entityPart = potentialEntity; const entityIdx = nonEmpty.indexOf(potentialEntity); pathParts = nonEmpty.slice(entityIdx + 1); } } // Build short label: prefix + ...last4 + /path if (entityPart) { const shortEntity = entityPart.length > 4 ? "..." + entityPart.slice(-4) : entityPart; const path = pathParts.length > 0 ? "/" + pathParts.join("/") : ""; const result = prefix + shortEntity + path; // If still too long, truncate path if (result.length > maxLen) { return result.slice(0, maxLen - 3) + "..."; } return result; } } // Fallback: simple truncation from end return label.slice(0, maxLen - 3) + "..."; } /** * Extract the entity ID from an action name. * Handles formats like: * - sink:did:key:.../of:entityId/path * - action:recipe:did:key:.../of:entityId/path */ private extractEntityId(actionId: string): string | undefined { // Look for "of:" pattern which precedes the entity ID const ofMatch = actionId.match(/\/of:([^\/]+)/); if (ofMatch) { return ofMatch[1]; } // Fallback: look for entity ID pattern after space identifier // Pattern: did:key:.../entityId/... const parts = actionId.split("/"); for (let i = 0; i < parts.length; i++) { const part = parts[i]; // Entity IDs are typically long base32/base58 strings if (part.length > 40 && !part.includes(":")) { return part; } } return undefined; } /** * Infer parent relationships for sinks that don't have explicit parents. * Groups sinks with non-sink actions that share the same entity ID. */ private inferParentsByEntity( nodes: SchedulerGraphNode[], ): Map { const inferredParents = new Map(); // Group nodes by entity ID const nodesByEntity = new Map(); for (const node of nodes) { const entityId = this.extractEntityId(node.id); if (entityId) { if (!nodesByEntity.has(entityId)) { nodesByEntity.set(entityId, []); } nodesByEntity.get(entityId)!.push(node); } } // For each entity group, find sinks without parents and assign them // to a non-sink action in the same group for (const [_entityId, groupNodes] of nodesByEntity) { // Find sink nodes without parents const orphanSinks = groupNodes.filter( (n) => n.id.startsWith("sink:") && !n.parentId, ); // Find the first non-sink action to use as parent // Prefer computations over effects as they're usually the "producer" const potentialParent = groupNodes.find( (n) => !n.id.startsWith("sink:") && n.type === "computation", ) || groupNodes.find((n) => !n.id.startsWith("sink:")); if (potentialParent && orphanSinks.length > 0) { for (const sink of orphanSinks) { inferredParents.set(sink.id, potentialParent.id); } } } return inferredParents; } private handleSnapshot(): void { this.debuggerController?.requestGraphSnapshot(); this.requestUpdate(); } private handleResetBaseline(): void { // Capture current stats as the baseline const newBaseline = new Map< string, { runCount: number; totalTime: number } >(); for (const node of this.layoutNodes.values()) { if (node.stats) { newBaseline.set(node.id, { runCount: node.stats.runCount, totalTime: node.stats.totalTime, }); } } // Store in controller so it persists across tab switches this.debuggerController?.setSchedulerBaselineStats(newBaseline); } private handleModeToggle(pullMode: boolean): void { const runtime = this.debuggerController?.getRuntime(); if (!runtime) return; const rt = runtime.runtime(); if (!rt) return; if (pullMode) { rt.scheduler.enablePullMode(); } else { rt.scheduler.disablePullMode(); } this.isPullMode = pullMode; // Request a new snapshot so the controller caches the updated pullMode value. // Without this, toggling the debugger off/on would reset to the stale mode. this.debuggerController?.requestGraphSnapshot(); } private handleEdgeClick(e: MouseEvent, edge: LayoutEdge): void { e.stopPropagation(); this.selectedNode = null; // Clear node selection if (this.selectedEdge?.id === edge.id) { this.selectedEdge = null; } else { this.selectedEdge = edge; this.tooltipPosition = { x: e.clientX, y: e.clientY }; } } private handleNodeClick(e: MouseEvent, node: LayoutNode): void { e.stopPropagation(); this.selectedEdge = null; // Clear edge selection if (this.selectedNode?.id === node.id) { this.selectedNode = null; } else { this.selectedNode = node; } } private handleContainerClick(): void { this.selectedEdge = null; this.selectedNode = null; } private selectNodeById(nodeId: string): void { const node = this.layoutNodes.get(nodeId); if (node) { this.selectedEdge = null; this.selectedNode = node; } } private getInboundNodes(nodeId: string): LayoutNode[] { // Find edges where this node is the target (other nodes depend on this) const inboundEdges = this.layoutEdges.filter( (e) => e.to === nodeId && e.edgeType !== "parent", ); const nodeIds = [...new Set(inboundEdges.map((e) => e.from))]; return nodeIds .map((id) => this.layoutNodes.get(id)) .filter((n): n is LayoutNode => n !== undefined); } private getOutboundNodes(nodeId: string): LayoutNode[] { // Find edges where this node is the source (this node depends on others) const outboundEdges = this.layoutEdges.filter( (e) => e.from === nodeId && e.edgeType !== "parent", ); const nodeIds = [...new Set(outboundEdges.map((e) => e.to))]; return nodeIds .map((id) => this.layoutNodes.get(id)) .filter((n): n is LayoutNode => n !== undefined); } private handleZoomIn(): void { this.zoomAroundCenter(this.zoomLevel * 1.25); } private handleZoomOut(): void { this.zoomAroundCenter(this.zoomLevel / 1.25); } private handleZoomReset(): void { this.zoomToFit(); } private centerOnNode(nodeId: string): void { const node = this.layoutNodes.get(nodeId); if (!node) return; const container = this.graphContainer; if (!container) return; // Set zoom to 50% const targetZoom = 0.5; this.zoomLevel = targetZoom; // After render, center on the node requestAnimationFrame(() => { const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Calculate scroll position to center the node const nodeCenterX = node.x + node.width / 2; const nodeCenterY = node.y + node.height / 2; const scrollLeft = nodeCenterX * targetZoom - containerWidth / 2; const scrollTop = nodeCenterY * targetZoom - containerHeight / 2; container.scrollLeft = Math.max(0, scrollLeft); container.scrollTop = Math.max(0, scrollTop); }); } private switchToGraphView(): void { const hadSelection = this.selectedNode !== null; const selectedId = this.selectedNode?.id; this.viewMode = "graph"; // If there was a selection, center on it after the graph renders if (hadSelection && selectedId) { // Wait for layout to complete before centering requestAnimationFrame(() => { requestAnimationFrame(() => { this.centerOnNode(selectedId); }); }); } } private handleWheel(e: WheelEvent): void { // Only zoom if ctrl/cmd is held, otherwise allow normal scroll if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); const container = this.graphContainer; if (!container) return; // Calculate zoom factor based on wheel delta const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(0.1, Math.min(5.0, this.zoomLevel * zoomFactor)); // Zoom around mouse position const rect = container.getBoundingClientRect(); const mouseX = e.clientX - rect.left + container.scrollLeft; const mouseY = e.clientY - rect.top + container.scrollTop; this.zoomAroundPoint(newZoom, mouseX, mouseY); } private zoomAroundPoint( newZoom: number, pointX: number, pointY: number, ): void { const container = this.graphContainer; if (!container) { this.zoomLevel = newZoom; return; } const oldZoom = this.zoomLevel; if (oldZoom === newZoom) return; // Convert point to content coordinates const contentX = pointX / oldZoom; const contentY = pointY / oldZoom; // Update zoom this.zoomLevel = newZoom; // After render, adjust scroll to keep point under cursor requestAnimationFrame(() => { const newScrollLeft = contentX * newZoom - (pointX - container.scrollLeft); const newScrollTop = contentY * newZoom - (pointY - container.scrollTop); container.scrollLeft = Math.max(0, newScrollLeft); container.scrollTop = Math.max(0, newScrollTop); }); } private zoomAroundCenter(newZoom: number): void { const container = this.graphContainer; if (!container) { this.zoomLevel = newZoom; return; } const oldZoom = this.zoomLevel; if (oldZoom === newZoom) return; // Get current scroll position and container dimensions const scrollLeft = container.scrollLeft; const scrollTop = container.scrollTop; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Calculate the center point in content coordinates (at old zoom) const centerX = (scrollLeft + containerWidth / 2) / oldZoom; const centerY = (scrollTop + containerHeight / 2) / oldZoom; // Update zoom this.zoomLevel = newZoom; // After render, adjust scroll to keep center point centered requestAnimationFrame(() => { const newScrollLeft = centerX * newZoom - containerWidth / 2; const newScrollTop = centerY * newZoom - containerHeight / 2; container.scrollLeft = Math.max(0, newScrollLeft); container.scrollTop = Math.max(0, newScrollTop); }); } private zoomToFit(): void { const container = this.graphContainer; if (!container) return; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Calculate zoom to fit with some padding const padding = 40; const zoomX = (containerWidth - padding) / this.svgWidth; const zoomY = (containerHeight - padding) / this.svgHeight; const fitZoom = Math.min(zoomX, zoomY, 1.0); // Don't zoom in beyond 100% this.zoomLevel = Math.max(0.1, fitZoom); } private get effectiveNodeWidth(): number { return NODE_WIDTH * this.zoomLevel; } private get shouldBoostTriggeredNodes(): boolean { return this.effectiveNodeWidth < XSchedulerGraph.READABLE_THRESHOLD; } private handleToggleCollapse(nodeId: string, e: Event): void { e.stopPropagation(); const newCollapsed = new Set(this.collapsedParents); if (newCollapsed.has(nodeId)) { newCollapsed.delete(nodeId); } else { newCollapsed.add(nodeId); } this.collapsedParents = newCollapsed; this.updateLayout(); } private renderToolbar(): TemplateResult { const nodeCount = this.layoutNodes.size; const edgeCount = this.layoutEdges.filter((e) => !e.isHistorical).length; const historicalCount = this.layoutEdges.filter((e) => e.isHistorical) .length; // Calculate lifetime totals (all runs since app start) let totalRuns = 0; let totalTime = 0; for (const node of this.layoutNodes.values()) { if (node.stats) { totalRuns += node.stats.runCount; totalTime += node.stats.totalTime; } } // Calculate delta since baseline (if baseline exists) const hasBaseline = this.baselineStats.size > 0; let totalRunsSinceBaseline = 0; let totalTimeSinceBaseline = 0; if (hasBaseline) { for (const node of this.layoutNodes.values()) { if (node.stats) { const baseline = this.baselineStats.get(node.id); totalRunsSinceBaseline += node.stats.runCount - (baseline?.runCount ?? 0); totalTimeSinceBaseline += node.stats.totalTime - (baseline?.totalTime ?? 0); } } } const formatTime = (ms: number) => { if (ms === 0) return "-"; if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`; if (ms < 1000) return `${ms.toFixed(1)}ms`; return `${(ms / 1000).toFixed(2)}s`; }; return html`
${this.viewMode === "graph" ? html`
` : ""}
Nodes: ${nodeCount} Edges: ${edgeCount} ${historicalCount > 0 ? html` Historical: ${historicalCount} ` : ""} Total: ${totalRuns} runs ${formatTime(totalTime)} ${hasBaseline ? this.renderBaselineStats( totalRunsSinceBaseline, totalTimeSinceBaseline, formatTime, ) : ""}
`; } /** * Render baseline stats separately to avoid deno fmt crash. * The crash occurs with deeply nested ternaries in class attributes * combined with multiline function calls in adjacent template expressions. */ private renderBaselineStats( runsDelta: number, timeDelta: number, formatTime: (ms: number) => string, ): TemplateResult { const runsClass = runsDelta > 0 ? "stat-value delta positive" : "stat-value delta"; const timeClass = timeDelta > 0 ? "stat-value delta positive" : "stat-value delta"; const runsPrefix = runsDelta > 0 ? "+" : ""; const timePrefix = timeDelta > 0 ? "+" : ""; return html` Δ: ${runsPrefix}${runsDelta} runs ${timePrefix}${formatTime(timeDelta)} `; } private renderNode(node: LayoutNode): TemplateResult { const isTriggered = this.triggeredNodes.has(node.id) && Date.now() - (this.triggeredNodes.get(node.id) ?? 0) < 2000; // Boost triggered nodes when zoomed out below readable threshold const shouldBoost = isTriggered && this.shouldBoostTriggeredNodes; const isSelected = this.selectedNode?.id === node.id; const nodeClass = [ "node-group", `node-${node.type}`, node.isDirty ? "node-dirty" : "", node.isPending ? "node-pending" : "", isTriggered ? "node-triggered" : "", shouldBoost ? "node-boosted" : "", isSelected ? "node-selected" : "", ] .filter(Boolean) .join(" "); const x = node.x - node.width / 2; const y = node.y - node.height / 2; // Build tooltip with preview and reads/writes diagnostic info let tooltip: string; if (node.type === "input") { tooltip = `${node.fullId}\n(Source cell - no writer)`; } else { const previewInfo = node.preview ? `\n\n${node.preview}` : ""; const readsInfo = node.reads?.length ? `\nReads (${node.reads.length}): ${ node.reads.slice(0, 5).join(", ") }${node.reads.length > 5 ? "..." : ""}` : "\nReads: none"; const writesInfo = node.writes?.length ? `\nWrites (${node.writes.length}): ${ node.writes.slice(0, 5).join(", ") }${node.writes.length > 5 ? "..." : ""}` : "\nWrites: none"; tooltip = `${node.fullId}${previewInfo}${readsInfo}${writesInfo}`; } return svgTag` ${tooltip} ${node.type === "effect" ? "E" : node.type === "input" ? "I" : "C"} ${node.label} ${ node.stats ? svgTag` ${node.stats.runCount}× ${node.stats.averageTime.toFixed(0)}ms ` : "" } ${this.renderCollapseToggle(node)} ${this.renderChildCountBadge(node)} `; } private renderCollapseToggle(node: LayoutNode): TemplateResult | null { // Only show toggle if node has children if (!node.childCount || node.childCount === 0) return null; const isCollapsed = this.collapsedParents.has(node.id); const symbol = isCollapsed ? "+" : "-"; return svgTag` ${symbol} `; } private renderChildCountBadge(node: LayoutNode): TemplateResult | null { // Only show badge if this node has collapsed children if (!node.collapsedChildCount || node.collapsedChildCount === 0) { return null; } return svgTag` (${node.collapsedChildCount} hidden) `; } private computeParentGroups(): Map< string, { parent: LayoutNode; children: LayoutNode[]; bounds: DOMRect } > { const groups = new Map< string, { parent: LayoutNode; children: LayoutNode[]; bounds: DOMRect } >(); // Find all parents that have visible children for (const node of this.layoutNodes.values()) { if (node.parentId) { const parent = this.layoutNodes.get(node.parentId); if (parent) { if (!groups.has(node.parentId)) { groups.set(node.parentId, { parent, children: [], bounds: new DOMRect(0, 0, 0, 0), }); } groups.get(node.parentId)!.children.push(node); } } } // Calculate bounding boxes for each group const padding = 15; for (const group of groups.values()) { const allNodes = [group.parent, ...group.children]; let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (const node of allNodes) { const left = node.x - node.width / 2; const top = node.y - node.height / 2; const right = node.x + node.width / 2; const bottom = node.y + node.height / 2; minX = Math.min(minX, left); minY = Math.min(minY, top); maxX = Math.max(maxX, right); maxY = Math.max(maxY, bottom); } group.bounds = new DOMRect( minX - padding, minY - padding, maxX - minX + padding * 2, maxY - minY + padding * 2, ); } return groups; } private renderParentGroups(): TemplateResult[] { const groups = this.computeParentGroups(); const results: TemplateResult[] = []; for (const group of groups.values()) { // Only render groups with at least one visible child if (group.children.length === 0) continue; const { bounds, parent } = group; const label = this.truncateLabel(parent.label, 12); results.push(svgTag` ${label} `); } return results; } private renderEdge(edge: LayoutEdge): TemplateResult | null { const source = this.layoutNodes.get(edge.from); const target = this.layoutNodes.get(edge.to); if (!source || !target) return null; // Calculate edge path (from bottom of source to top of target) const x1 = source.x; const y1 = source.y + source.height / 2; const x2 = target.x; const y2 = target.y - target.height / 2; // Simple straight line (could add bezier curves later) const path = `M ${x1} ${y1} L ${x2} ${y2}`; const edgeClasses = [ "edge-path", edge.isHistorical ? "edge-historical" : "edge-current", edge.edgeType === "parent" ? "edge-parent" : "", ].filter(Boolean).join(" "); return svgTag` `; } private renderGraph(): TemplateResult { if (this.layoutNodes.size === 0) { return html`
No graph data available
`; } const scaledWidth = this.svgWidth * this.zoomLevel; const scaledHeight = this.svgHeight * this.zoomLevel; return html` ${this.renderParentGroups()} ${this.layoutEdges.map((edge) => this.renderEdge(edge))} ${[...this.layoutNodes.values()].map((node) => this.renderNode(node))} `; } private renderTooltip(): TemplateResult | null { if (!this.selectedEdge) return null; const fromNode = this.layoutNodes.get(this.selectedEdge.from); const toNode = this.layoutNodes.get(this.selectedEdge.to); return html`
${fromNode?.label ?? this.selectedEdge.from} → ${toNode?.label ?? this.selectedEdge.to}
${this.selectedEdge.cells.length > 0 ? this.selectedEdge.cells.map( (cell) => html`
${cell}
`, ) : html`
(no cells tracked)
`}
${this.selectedEdge.isHistorical ? html`
Historical (no longer active)
` : ""}
`; } private renderDetailPane(): TemplateResult | null { if (!this.selectedNode && !this.selectedEdge) return null; const formatTime = (ms: number) => { if (ms === 0) return "-"; if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`; if (ms < 1000) return `${ms.toFixed(1)}ms`; return `${(ms / 1000).toFixed(2)}s`; }; const formatDelta = (delta: number, isTime: boolean = false): string => { if (delta === 0) return ""; const sign = delta > 0 ? "+" : ""; if (isTime) { if (Math.abs(delta) < 1) return `${sign}${(delta * 1000).toFixed(0)}µs`; if (Math.abs(delta) < 1000) return `${sign}${delta.toFixed(1)}ms`; return `${sign}${(delta / 1000).toFixed(2)}s`; } return `${sign}${delta}`; }; if (this.selectedNode) { const node = this.selectedNode; const baseline = this.baselineStats.get(node.id); const hasBaseline = this.baselineStats.size > 0; const renderStatWithDelta = ( label: string, value: number, baselineValue: number | undefined, isTime: boolean = false, ) => { const delta = value - (baselineValue ?? 0); const deltaStr = formatDelta(delta, isTime); return html`
${label}
${isTime ? formatTime(value) : value}
${hasBaseline && deltaStr ? html`
${deltaStr}
` : ""}
`; }; return html`
${node.type} ${node.label}
ID
${node.fullId}
${node.stats ? html`
Stats
${renderStatWithDelta( "Runs", node.stats.runCount, baseline?.runCount, )} ${renderStatWithDelta( "Total", node.stats.totalTime, baseline?.totalTime, true, )}
Average
${formatTime(node.stats.averageTime)}
Last
${formatTime(node.stats.lastRunTime)}
` : ""} ${node.preview ? html`
Code Preview
${node.preview}
` : ""} ${node.reads && node.reads.length > 0 ? html`
Reads (${node.reads.length})
${node.reads.map( (r) => html`
${r}
`, )}
` : ""} ${node.writes && node.writes.length > 0 ? html`
Writes (${node.writes.length})
${node.writes.map( (w) => html`
${w}
`, )}
` : ""} ${(() => { const inbound = this.getInboundNodes(node.id); return inbound.length > 0 ? html`
Dependents (${inbound.length})
${inbound.map( (n) => html`
${n .type} ${n .label}
`, )}
` : ""; })()} ${(() => { const outbound = this.getOutboundNodes(node.id); return outbound.length > 0 ? html`
Dependencies (${outbound.length})
${outbound.map( (n) => html`
${n .type} ${n .label}
`, )}
` : ""; })()}
`; } if (this.selectedEdge) { const fromNode = this.layoutNodes.get(this.selectedEdge.from); const toNode = this.layoutNodes.get(this.selectedEdge.to); return html`
Edge
From
${fromNode?.label ?? this.selectedEdge.from}
To
${toNode?.label ?? this.selectedEdge.to}
Cells (${this.selectedEdge.cells.length})
${this.selectedEdge.cells.length > 0 ? this.selectedEdge.cells.map( (c) => html`
${c}
`, ) : html`
(no cells tracked)
`}
${this.selectedEdge.isHistorical ? html`
This edge is historical (no longer active)
` : ""}
`; } return null; } private renderLegend(): TemplateResult { return html`
Input
Computation
Effect
Data
Parent
`; } override render(): TemplateResult { return html` ${this.renderToolbar()} ${this.viewMode === "graph" ? html`
${this.renderGraph()} ${this.renderTooltip()} ${this .renderLegend()}
${this.renderDetailPane()}
` : this.renderTable()} `; } private renderTable(): TemplateResult { // Get all nodes with stats const allNodesWithStats = Array.from(this.layoutNodes.values()) .filter((n) => n.type !== "input" && n.stats) .map((n) => ({ id: n.id, fullId: n.fullId, label: n.label, type: n.type, preview: n.preview, parentId: n.parentId, runCount: n.stats?.runCount ?? 0, totalTime: n.stats?.totalTime ?? 0, avgTime: n.stats?.averageTime ?? 0, lastTime: n.stats?.lastRunTime ?? 0, lastTimestamp: n.stats?.lastRunTimestamp ?? 0, reads: n.reads, writes: n.writes, debounceMs: n.debounceMs, throttleMs: n.throttleMs, })); // Build parent-child hierarchy with aggregated stats type NodeWithStats = (typeof allNodesWithStats)[0]; interface GroupedNode extends NodeWithStats { children: NodeWithStats[]; aggregatedTotalTime: number; aggregatedRunCount: number; // Delta values (since baseline) deltaRunCount: number; deltaTotalTime: number; aggregatedDeltaRunCount: number; aggregatedDeltaTotalTime: number; isParent: boolean; } const nodeById = new Map(allNodesWithStats.map((n) => [n.id, n])); const childrenByParent = new Map(); // Helper to get delta for a node const getNodeDelta = (id: string, runCount: number, totalTime: number) => { const baseline = this.baselineStats.get(id); return { deltaRunCount: runCount - (baseline?.runCount ?? 0), deltaTotalTime: totalTime - (baseline?.totalTime ?? 0), }; }; // Group children by parent for (const node of allNodesWithStats) { if (node.parentId && nodeById.has(node.parentId)) { if (!childrenByParent.has(node.parentId)) { childrenByParent.set(node.parentId, []); } childrenByParent.get(node.parentId)!.push(node); } } // Create grouped nodes (top-level = no parent or parent not in our list) const groupedNodes: GroupedNode[] = []; const processedChildren = new Set(); for (const node of allNodesWithStats) { // Skip if this is a child of a visible parent if (node.parentId && nodeById.has(node.parentId)) { processedChildren.add(node.id); continue; } const children = childrenByParent.get(node.id) ?? []; const aggregatedTotalTime = node.totalTime + children.reduce((sum, c) => sum + c.totalTime, 0); const aggregatedRunCount = node.runCount + children.reduce((sum, c) => sum + c.runCount, 0); // Calculate deltas const nodeDelta = getNodeDelta(node.id, node.runCount, node.totalTime); const childrenDeltaRunCount = children.reduce((sum, c) => { const d = getNodeDelta(c.id, c.runCount, c.totalTime); return sum + d.deltaRunCount; }, 0); const childrenDeltaTotalTime = children.reduce((sum, c) => { const d = getNodeDelta(c.id, c.runCount, c.totalTime); return sum + d.deltaTotalTime; }, 0); groupedNodes.push({ ...node, children, aggregatedTotalTime, aggregatedRunCount, deltaRunCount: nodeDelta.deltaRunCount, deltaTotalTime: nodeDelta.deltaTotalTime, aggregatedDeltaRunCount: nodeDelta.deltaRunCount + childrenDeltaRunCount, aggregatedDeltaTotalTime: nodeDelta.deltaTotalTime + childrenDeltaTotalTime, isParent: children.length > 0, }); } // Sort based on current column (and whether we're sorting by delta) const useDelta = this.sortByDelta && this.baselineStats.size > 0; const sortNodes = (nodes: GroupedNode[]) => { nodes.sort((a, b) => { let cmp = 0; switch (this.tableSortColumn) { case "totalTime": cmp = useDelta ? b.aggregatedDeltaTotalTime - a.aggregatedDeltaTotalTime : b.aggregatedTotalTime - a.aggregatedTotalTime; break; case "runCount": cmp = useDelta ? b.aggregatedDeltaRunCount - a.aggregatedDeltaRunCount : b.aggregatedRunCount - a.aggregatedRunCount; break; case "avgTime": cmp = b.avgTime - a.avgTime; break; case "lastTime": cmp = b.lastTime - a.lastTime; break; } return this.tableSortAscending ? -cmp : cmp; }); }; sortNodes(groupedNodes); const sortIndicator = (col: typeof this.tableSortColumn) => { const isSorted = this.tableSortColumn === col; const arrow = this.tableSortAscending ? "▲" : "▼"; return html` ${isSorted ? arrow : ""} `; }; const handleSort = (col: typeof this.tableSortColumn) => { if (this.tableSortColumn === col) { this.tableSortAscending = !this.tableSortAscending; } else { this.tableSortColumn = col; this.tableSortAscending = false; } }; const formatTime = (ms: number) => { if (ms === 0) return "-"; if (ms < 1) return `${(ms * 1000).toFixed(0)}µs`; if (ms < 1000) return `${ms.toFixed(1)}ms`; return `${(ms / 1000).toFixed(2)}s`; }; const hasBaseline = this.baselineStats.size > 0; const getBaseline = (id: string) => this.baselineStats.get(id); const getDelta = ( current: number, baseline: number | undefined, ): number => { return current - (baseline ?? 0); }; const formatDelta = (delta: number, isTime: boolean = false): string => { if (delta === 0) return ""; const sign = delta > 0 ? "+" : ""; if (isTime) { if (Math.abs(delta) < 1) return `${sign}${(delta * 1000).toFixed(0)}µs`; if (Math.abs(delta) < 1000) return `${sign}${delta.toFixed(1)}ms`; return `${sign}${(delta / 1000).toFixed(2)}s`; } return `${sign}${delta}`; }; const renderStatWithDelta = ( value: number, baselineValue: number | undefined, isTime: boolean = false, ) => { const delta = getDelta(value, baselineValue); const formatted = isTime ? formatTime(value) : String(value); if (!hasBaseline) { return formatted; } const deltaStr = formatDelta(delta, isTime); return html`
${formatted} ${deltaStr ? html` ${deltaStr} ` : ""}
`; }; const toggleExpand = (id: string, e: Event) => { e.stopPropagation(); if (this.tableExpandedParents.has(id)) { this.tableExpandedParents.delete(id); } else { this.tableExpandedParents.add(id); } this.requestUpdate(); }; const handleRowClick = (node: NodeWithStats) => { // Find the layout node and select it const layoutNode = this.layoutNodes.get(node.id); if (layoutNode) { this.selectedNode = layoutNode; this.selectedEdge = null; } }; const renderRow = ( n: NodeWithStats, isChild: boolean = false, _parentNode?: GroupedNode, ) => { const isSelected = this.selectedNode?.id === n.id; const baseline = getBaseline(n.id); return html` ${n.type} ${isChild ? html` └─ ` : ""} ${n.label} ${n.debounceMs ? html` D:${n.debounceMs}ms ` : ""} ${n.throttleMs ? html` T:${n.throttleMs}ms ` : ""} ${renderStatWithDelta(n.runCount, baseline?.runCount)} ${renderStatWithDelta(n.totalTime, baseline?.totalTime, true)} ${formatTime(n.avgTime)} ${formatTime(n.lastTime)} `; }; const renderGroupedRow = (n: GroupedNode) => { const isExpanded = this.tableExpandedParents.has(n.id); const isSelected = this.selectedNode?.id === n.id; const rows: TemplateResult[] = []; // Get baseline for this node and calculate aggregated baseline const baseline = getBaseline(n.id); const aggregatedBaselineRunCount = (baseline?.runCount ?? 0) + n.children.reduce( (sum, c) => sum + (getBaseline(c.id)?.runCount ?? 0), 0, ); const aggregatedBaselineTotalTime = (baseline?.totalTime ?? 0) + n.children.reduce( (sum, c) => sum + (getBaseline(c.id)?.totalTime ?? 0), 0, ); // Parent row with aggregated stats rows.push(html` ${n.type} ${n.isParent ? html` ` : ""} ${n.label} ${n.isParent ? html` (${n.children.length}) ` : ""} ${n.debounceMs ? html` D:${n.debounceMs}ms ` : ""} ${n.throttleMs ? html` T:${n.throttleMs}ms ` : ""} ${n.isParent && !isExpanded ? html` ${renderStatWithDelta( n.aggregatedRunCount, hasBaseline ? aggregatedBaselineRunCount : undefined, )} ` : renderStatWithDelta(n.runCount, baseline?.runCount)} ${n.isParent && !isExpanded ? html` ${renderStatWithDelta( n.aggregatedTotalTime, hasBaseline ? aggregatedBaselineTotalTime : undefined, true, )} ` : renderStatWithDelta(n.totalTime, baseline?.totalTime, true)} ${formatTime(n.avgTime)} ${formatTime(n.lastTime)} `); // Child rows (if expanded) if (isExpanded && n.children.length > 0) { // Sort children too (using delta if enabled) const sortedChildren = [...n.children].sort((a, b) => { let cmp = 0; const aDelta = getNodeDelta(a.id, a.runCount, a.totalTime); const bDelta = getNodeDelta(b.id, b.runCount, b.totalTime); switch (this.tableSortColumn) { case "totalTime": cmp = useDelta ? bDelta.deltaTotalTime - aDelta.deltaTotalTime : b.totalTime - a.totalTime; break; case "runCount": cmp = useDelta ? bDelta.deltaRunCount - aDelta.deltaRunCount : b.runCount - a.runCount; break; case "avgTime": cmp = b.avgTime - a.avgTime; break; case "lastTime": cmp = b.lastTime - a.lastTime; break; } return this.tableSortAscending ? -cmp : cmp; }); for (const child of sortedChildren) { rows.push(renderRow(child, true, n)); } } return rows; }; return html`
${groupedNodes.map((n) => renderGroupedRow(n))}
Type Action Runs ${sortIndicator("runCount")} Total ${sortIndicator("totalTime")} ${hasBaseline ? html` ` : ""} Avg ${sortIndicator("avgTime")} Last ${sortIndicator("lastTime")}
${this.renderDetailPane()}
`; } } globalThis.customElements.define("x-scheduler-graph", XSchedulerGraph);