// Copyright Epic Games, Inc. All Rights Reserved. // Canvas-drawn flame graph with per-thread swimlanes, pan+zoom, hover // tooltip, click-to-select and scope-name highlighting. const MAX_DRAWN_DEPTH = 32; const MIN_RECT_W = 1.5; // don't draw narrower than this (px) const RULER_H = 20; const THREAD_GAP = 6; const PADDING_X = 0; const REGIONS_GAP = 6; // gap between the region rack and the first thread // Per-mode row metrics. Compact mode (toggled with 'c') shrinks every // vertical dimension so the flame graph fits many more threads/depths in // the viewport at the cost of in-bar text labels. const METRICS_NORMAL = { headerH: 18, // thread name row height depthH: 16, // scope lane row height regionLaneH: 18, // region band row height regionHeaderH: 16, // region category header row height scopeFontPx: 11, // in-bar scope label font size headerFontPx: 11, // thread header / region row font size showLabels: true, }; const METRICS_COMPACT = { headerH: 12, depthH: 4, regionLaneH: 6, regionHeaderH: 12, scopeFontPx: 0, // labels too tall to fit; suppressed headerFontPx: 9, showLabels: false, }; // Scope colors: golden-angle hue rotation keyed on NameId so the same scope // always renders in the same color across zoom levels. function scopeFillColor(nameId) { const hue = ((nameId * 137.508) % 360 + 360) % 360; return `hsl(${hue.toFixed(0)}, 55%, 42%)`; } function scopeHighlightColor(nameId) { const hue = ((nameId * 137.508) % 360 + 360) % 360; return `hsl(${hue.toFixed(0)}, 80%, 60%)`; } function stringHash(s) { let h = 0; for (let i = 0; i < s.length; i++) { h = ((h << 5) - h + s.charCodeAt(i)) | 0; } return h >>> 0; } // Desaturated palette for regions so they don't compete visually with the // colourful CPU scopes below them. function regionFillColor(name) { const hue = (stringHash(name || "region") * 2.3) % 360; return `hsla(${hue.toFixed(0)}, 35%, 55%, 0.55)`; } function formatTime(us) { if (us < 1000) { return `${us} µs`; } if (us < 1_000_000) { return `${(us / 1000).toFixed(3)} ms`; } return `${(us / 1_000_000).toFixed(3)} s`; } function formatRange(startUs, endUs) { return `${formatTime(startUs)} → ${formatTime(endUs)} (${formatTime(endUs - startUs)})`; } export class Timeline { constructor(opts) { this.canvas = opts.canvas; this.tooltip = opts.tooltip; this.selectionEl = opts.selectionEl; this.viewportInfoEl = opts.viewportInfoEl; this.zoomResetBtn = opts.zoomResetBtn; this.model = opts.model; this.onScopeSelect = opts.onScopeSelect || (() => {}); this.ctx = this.canvas.getContext("2d"); this.dpr = Math.max(1, window.devicePixelRatio || 1); this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us); this.bookmarksVisible = true; this.compact = false; this.metrics = METRICS_NORMAL; this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0); // All categories enabled by default; renderRegionCategories() calls // setEnabledRegionCategories() shortly after construction. this.enabledRegionCategories = new Set(this.regionCategories.map((_, i) => i)); this.recomputeRegionsBlockH(); // Per-thread timelines keyed by threadId; each entry is an object // { scopes, perDepth } where scopes is an array of tuples // [beginUs, durationUs, nameId, depth, mergeCount?]. this.timelines = new Map(); // Set of threadIds the user wants visible. this.enabledThreads = new Set(); // Viewport-driven fetch state. this.lodEnabled = true; // when false, always request LOD 0 (raw) this.fetchThrottled = false; this.fetchPending = false; this.fetchThrottleTimer = null; this.abortControllers = new Map(); // threadId → AbortController this.fetchSeq = new Map(); // threadId → monotonic fetch sequence id this.cachedRanges = new Map(); // threadId → { startUs, endUs, resolution } // Lookup helpers. this.threadMeta = new Map(); // threadId → { name, sortHint, scopeCount } for (const t of this.model.threads) { this.threadMeta.set(t.thread_id, t); } // Viewport state — time units throughout are microseconds from trace start. this.traceStart = 0; this.traceEnd = Math.max(1, this.model.session.trace_end_us || 0); if (this.traceEnd <= this.traceStart) { this.traceEnd = this.traceStart + 1000; } this.startUs = this.traceStart; const maxInitialUs = 60_000_000; // cap initial view to 60 seconds this.endUs = (this.traceEnd - this.traceStart > maxInitialUs) ? this.traceStart + maxInitialUs : this.traceEnd; // Vertical scroll offset in canvas pixels (0 = first thread flush // against the ruler). Updated by both drag-pan and shift-wheel. this.scrollY = 0; this.maxScrollY = 0; // Hit-test rects computed during the last draw. this.hits = []; this.selectedId = null; this.highlightName = null; // Pan state this.panStartX = 0; this.panStartY = 0; this.panStartUs = 0; this.panStartScrollY = 0; this.panning = false; this.panMoved = false; this.resizeObserver = new ResizeObserver(() => this.requestDraw()); this.resizeObserver.observe(this.canvas); this.canvas.addEventListener("mousedown", (e) => this.onMouseDown(e)); window.addEventListener("mousemove", (e) => this.onMouseMove(e)); window.addEventListener("mouseup", (e) => this.onMouseUp(e)); this.canvas.addEventListener("wheel", (e) => this.onWheel(e), { passive: false }); this.canvas.addEventListener("mouseleave", () => this.hideTooltip()); this.zoomResetBtn.addEventListener("click", () => this.resetView()); this.drawPending = false; } setBookmarksVisible(visible) { this.bookmarksVisible = visible; this.requestDraw(); } setCompact(compact) { const next = !!compact; if (next === this.compact) return; this.compact = next; this.metrics = next ? METRICS_COMPACT : METRICS_NORMAL; this.recomputeRegionsBlockH(); this.requestDraw(); } toggleCompact() { this.setCompact(!this.compact); } setEnabledRegionCategories(indices) { this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices); this.recomputeRegionsBlockH(); this.requestDraw(); } recomputeRegionsBlockH() { this.regionsBlockH = 0; const M = this.metrics || METRICS_NORMAL; for (let i = 0; i < this.regionCategories.length; i++) { if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue; const cat = this.regionCategories[i]; this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH; } if (this.regionsBlockH > 0) { this.regionsBlockH += REGIONS_GAP; } } setLodEnabled(enabled) { this.lodEnabled = enabled; // Invalidate all caches so the next fetch uses the new setting. this.cachedRanges.clear(); this.scheduleFetch(); } setEnabledThreads(ids) { this.enabledThreads = new Set(ids); this.scheduleFetch(); this.requestDraw(); } setHighlightName(name) { this.highlightName = name || null; this.requestDraw(); } jumpToScopeName(name) { // Find the first scope with the given name and frame it. this.setHighlightName(name); for (const threadId of this.enabledThreads) { const timeline = this.timelines.get(threadId); if (!timeline) continue; for (const s of timeline.scopes) { const nameId = s[2]; if (this.model.scopeNames[nameId] !== name) continue; const beginUs = s[0]; const durationUs = s[1]; const pad = Math.max(durationUs * 3, 500); this.startUs = Math.max(0, beginUs - pad); this.endUs = beginUs + durationUs + pad; this.selectScope({ threadId, tuple: s }); this.scheduleFetch(); this.requestDraw(); return; } } } resetView() { this.startUs = this.traceStart; this.endUs = this.traceEnd; this.scrollY = 0; this.scheduleFetch(); this.requestDraw(); } // ── Viewport-driven fetch engine ────────────────────────────────── computeResolution() { const w = this.width || this.canvas.getBoundingClientRect().width || 1; // The resolution tells the server the minimum renderable scope duration. // A scope must be at least MIN_RECT_W pixels wide to be drawn, so the // threshold is usPerPixel * MIN_RECT_W, not just usPerPixel. This // selects a coarser LOD that merges across gaps smaller than one // renderable unit, preventing empty holes in the timeline. return Math.ceil((this.endUs - this.startUs) / w * MIN_RECT_W); } computeFetchWindow() { const range = this.endUs - this.startUs; const margin = range * 0.5; return { startUs: Math.max(0, Math.floor(this.startUs - margin)), endUs: Math.ceil(this.endUs + margin), }; } // Map a resolution to the LOD index the server would select. // Mirrors the server's selection: finest LOD where ResolutionUs >= res. // Returns -1 for LOD 0 (raw), 0–4 for LOD 1–5. lodForResolution(res) { if (!this.lodEnabled || res <= 0) return -1; const levels = [100, 1000, 8000, 40000, 200000]; for (let i = 0; i < levels.length; i++) { if (levels[i] >= res) return i; } return levels.length - 1; // coarsest } needsRefetch(threadId) { const cached = this.cachedRanges.get(threadId); if (!cached) return true; const currentRes = this.computeResolution(); // Re-fetch when the LOD level would change — this catches the exact // boundary crossing and prevents jarring LOD transitions during pan. if (this.lodForResolution(cached.resolution) !== this.lodForResolution(currentRes)) return true; // Re-fetch when the viewport nears the edge of the cached range. const margin = (cached.endUs - cached.startUs) * 0.25; if (this.startUs < cached.startUs + margin) return true; if (this.endUs > cached.endUs - margin) return true; return false; } checkViewportFetch() { for (const id of this.enabledThreads) { if (this.needsRefetch(id)) { this.scheduleFetch(); return; } } } scheduleFetch() { // Leading+trailing throttle: fires immediately on the first call, // then suppresses further calls for 150ms. If any calls arrived // during the suppression window, one trailing fetch fires at the end. // This keeps data flowing during continuous pan/zoom without flooding. this.fetchPending = true; if (this.fetchThrottled) return; this.fetchThrottled = true; this.fetchPending = false; this.fetchViewport(); this.fetchThrottleTimer = setTimeout(() => { this.fetchThrottled = false; if (this.fetchPending) { this.fetchPending = false; this.scheduleFetch(); } }, 150); } async fetchViewport() { const { startUs, endUs } = this.computeFetchWindow(); const currentRes = this.lodEnabled ? this.computeResolution() : 0; const threadIds = []; let resolution = currentRes; for (const threadId of this.enabledThreads) { if (!this.needsRefetch(threadId)) continue; // If the LOD level hasn't changed, reuse the cached resolution so // the server selects the same LOD. This prevents a pan-triggered // refetch from accidentally switching LOD levels due to minor // resolution drift within the same LOD band. const cached = this.cachedRanges.get(threadId); if (cached && currentRes > 0 && this.lodForResolution(cached.resolution) === this.lodForResolution(currentRes)) { resolution = cached.resolution; } threadIds.push(threadId); } if (threadIds.length === 0) return; // Cancel any in-flight batch request. if (this.batchAbort) this.batchAbort.abort(); const controller = new AbortController(); this.batchAbort = controller; const seq = (this.batchSeq || 0) + 1; this.batchSeq = seq; try { const { getTimelineBatch } = await import("./api.js"); const result = await getTimelineBatch(threadIds, startUs, endUs, 0, resolution, { signal: controller.signal }); // Discard stale responses. if (this.batchSeq !== seq) return; for (const threadId of threadIds) { const entry = result[String(threadId)]; const scopes = entry ? (entry.scopes || []) : []; const perDepth = []; for (let i = 0; i < scopes.length; i++) { const d = scopes[i][3]; while (perDepth.length <= d) perDepth.push([]); perDepth[d].push(i); } this.timelines.set(threadId, { scopes, perDepth }); this.cachedRanges.set(threadId, { startUs, endUs, resolution }); } this.batchAbort = null; this.requestDraw(); } catch (e) { if (e.name === "AbortError") return; console.error(`failed to load timeline batch: ${e.message}`); this.batchAbort = null; } } requestDraw() { if (this.drawPending) return; this.drawPending = true; requestAnimationFrame(() => { this.drawPending = false; this.draw(); }); } resizeBackingStore() { const rect = this.canvas.getBoundingClientRect(); this.width = Math.floor(rect.width); this.height = Math.floor(rect.height); const bw = Math.floor(rect.width * this.dpr); const bh = Math.floor(rect.height * this.dpr); if (this.canvas.width !== bw || this.canvas.height !== bh) { this.canvas.width = bw; this.canvas.height = bh; } this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); } pxPerUs() { return (this.width - PADDING_X * 2) / Math.max(1, this.endUs - this.startUs); } usAtX(x) { return this.startUs + (x - PADDING_X) / this.pxPerUs(); } xAtUs(us) { return PADDING_X + (us - this.startUs) * this.pxPerUs(); } layoutThreads() { // Returns { rows: [{threadId, y, maxDepth}], totalH }. The y values // are in canvas coordinates with the current scrollY already applied. const rows = []; let y = RULER_H + this.regionsBlockH - this.scrollY; const sorted = Array.from(this.enabledThreads) .filter((id) => this.threadMeta.has(id)) .sort((a, b) => { const ma = this.threadMeta.get(a); const mb = this.threadMeta.get(b); // OS threads first, then lanes if (ma.is_lane !== mb.is_lane) return ma.is_lane ? 1 : -1; // Group by group name (ungrouped first) const ga = ma.group || ""; const gb = mb.group || ""; if (ga !== gb) { if (!ga) return -1; if (!gb) return 1; return ga.localeCompare(gb, undefined, { numeric: true }); } // Match sidebar sort: sort_hint → scopes-first → thread_id → name if (ma.sort_hint !== mb.sort_hint) return ma.sort_hint - mb.sort_hint; if ((ma.scope_count > 0) !== (mb.scope_count > 0)) return mb.scope_count - ma.scope_count; if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id; return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true }); }); const M = this.metrics; for (const threadId of sorted) { const timeline = this.timelines.get(threadId); const scopes = timeline ? timeline.scopes : []; let maxDepth = 0; for (const s of scopes) { if (s[3] > maxDepth) maxDepth = s[3]; } if (maxDepth > MAX_DRAWN_DEPTH) maxDepth = MAX_DRAWN_DEPTH; const rowH = M.headerH + (maxDepth + 1) * M.depthH; rows.push({ threadId, y, headerH: M.headerH, maxDepth, height: rowH }); y += rowH + THREAD_GAP; } // y now points at the bottom of the last row in scrolled coords. // Recover the unscrolled total content height for scroll clamping. const totalContentH = y + this.scrollY; return { rows, totalH: totalContentH }; } draw() { this.resizeBackingStore(); const ctx = this.ctx; const W = this.width; const H = this.height; ctx.fillStyle = getComputedStyle(document.body).getPropertyValue("--bg0") || "#0d1117"; ctx.fillRect(0, 0, W, H); this.hits = []; // First pass: lay out threads to discover total content height and // clamp scrollY. We may re-layout after clamping so coordinates // are accurate for the real draw. let layout = this.layoutThreads(); const visibleH = Math.max(0, H - RULER_H); this.maxScrollY = Math.max(0, layout.totalH - RULER_H - visibleH); if (this.scrollY > this.maxScrollY) { this.scrollY = this.maxScrollY; layout = this.layoutThreads(); } if (this.scrollY < 0) { this.scrollY = 0; layout = this.layoutThreads(); } const { rows } = layout; // Clip thread rendering to below the ruler strip so scrolled-up // content never bleeds over it. The ruler is drawn after restoring. ctx.save(); ctx.beginPath(); ctx.rect(0, RULER_H, W, H - RULER_H); ctx.clip(); this.drawRegions(ctx, W); const pxPerUs = this.pxPerUs(); const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22"; const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9"; const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; const M = this.metrics; for (const row of rows) { if (row.y > H) break; if (row.y + row.height < RULER_H) continue; // Thread header strip const meta = this.threadMeta.get(row.threadId); const isLane = meta && meta.is_lane; ctx.fillStyle = isLane ? "rgba(130, 80, 220, 0.12)" : bg1; ctx.fillRect(0, row.y, W, row.headerH); const prefix = isLane ? "⬦ " : ""; const label = `${prefix}${(meta && meta.name) || `tid ${row.threadId}`} · ${row.threadId}`; ctx.fillStyle = isLane ? "rgba(180, 140, 255, 0.8)" : fg2; ctx.font = `${M.headerFontPx}px -apple-system, Segoe UI, sans-serif`; ctx.textBaseline = "middle"; ctx.fillText(label, 6, row.y + row.headerH / 2); // Swimlane backgrounds for (let d = 0; d <= row.maxDepth; d++) { ctx.fillStyle = d % 2 === 0 ? "rgba(255,255,255,0.015)" : "rgba(255,255,255,0.00)"; ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH); } const timeline = this.timelines.get(row.threadId); if (timeline) { this.drawScopes(ctx, timeline, row, pxPerUs, fg1); } } this.drawSelectionOutline(ctx); ctx.restore(); // Ruler is drawn last so it always overlays the thread region // regardless of how far the content has scrolled. this.drawRuler(ctx, W); // Bookmark lines span the whole content area, drawn after the ruler // so the little diamond markers sit inside the ruler strip. this.drawBookmarks(ctx, W, H); this.drawViewportInfo(); } drawRuler(ctx, W) { const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22"; const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; const border = getComputedStyle(document.body).getPropertyValue("--border") || "#30363d"; ctx.fillStyle = bg1; ctx.fillRect(0, 0, W, RULER_H); ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, RULER_H - 0.5); ctx.lineTo(W, RULER_H - 0.5); ctx.stroke(); // Pick a tick interval that yields 6–12 ticks across the visible range. const rangeUs = this.endUs - this.startUs; const targetTicks = Math.max(4, Math.min(12, Math.floor(W / 100))); const roughInterval = rangeUs / targetTicks; const pow10 = Math.pow(10, Math.floor(Math.log10(roughInterval))); let interval = pow10; if (roughInterval / pow10 > 5) interval = 10 * pow10; else if (roughInterval / pow10 > 2) interval = 5 * pow10; else if (roughInterval / pow10 > 1) interval = 2 * pow10; ctx.fillStyle = fg2; ctx.font = "10px -apple-system, Segoe UI, sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; const firstTick = Math.ceil(this.startUs / interval) * interval; for (let t = firstTick; t <= this.endUs; t += interval) { const x = this.xAtUs(t); ctx.strokeStyle = border; ctx.beginPath(); ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, RULER_H); ctx.stroke(); ctx.fillText(formatTime(t), x + 4, RULER_H / 2); } } drawRegions(ctx, W) { if (this.regionCategories.length === 0) return; const M = this.metrics; const startUs = this.startUs; const endUs = this.endUs; const pxPerUs = this.pxPerUs(); const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; const bg1 = getComputedStyle(document.body).getPropertyValue("--bg1") || "#161b22"; let catY = RULER_H - this.scrollY; for (let ci = 0; ci < this.regionCategories.length; ci++) { if (!this.enabledRegionCategories.has(ci)) continue; const cat = this.regionCategories[ci]; // Category header ctx.fillStyle = bg1; ctx.fillRect(0, catY, W, M.regionHeaderH); if (M.showLabels) { ctx.fillStyle = fg2; ctx.font = "10px -apple-system, Segoe UI, sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + M.regionHeaderH / 2); } catY += M.regionHeaderH; // Region bands for this category for (const r of cat.regions) { const beginUs = r.begin_us; const endRegUs = r.end_us; if (endRegUs < startUs) continue; if (beginUs > endUs) continue; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs); if (w < MIN_RECT_W) continue; const y = catY + r.depth * M.regionLaneH; ctx.fillStyle = regionFillColor(r.name); ctx.fillRect(x, y + 1, w, M.regionLaneH - 2); ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 1; ctx.strokeRect(x + 0.5, y + 1.5, w - 1, M.regionLaneH - 3); const visX = Math.max(x, 0); const visRight = Math.min(x + w, this.width); const visW = visRight - visX; if (M.showLabels && visW > 24 && r.name) { ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.font = "11px -apple-system, Segoe UI, sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.save(); ctx.beginPath(); ctx.rect(visX + 3, y, visW - 6, M.regionLaneH); ctx.clip(); ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2); ctx.restore(); } this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name }); } catY += cat.lane_count * M.regionLaneH; } } drawBookmarks(ctx, W, H) { if (!this.bookmarksVisible || !this.bookmarks || this.bookmarks.length === 0) return; const startUs = this.startUs; const endUs = this.endUs; ctx.save(); ctx.strokeStyle = "rgba(227, 179, 65, 0.85)"; ctx.fillStyle = "rgba(227, 179, 65, 0.95)"; ctx.lineWidth = 1; for (const b of this.bookmarks) { if (b.time_us < startUs) continue; if (b.time_us > endUs) break; const x = this.xAtUs(b.time_us); if (x < -2 || x > W + 2) continue; // Dashed vertical line spanning the whole content area. ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(x + 0.5, RULER_H); ctx.lineTo(x + 0.5, H); ctx.stroke(); ctx.setLineDash([]); // Diamond marker inside the ruler strip. const cy = RULER_H - 6; ctx.beginPath(); ctx.moveTo(x, cy - 4); ctx.lineTo(x + 4, cy); ctx.lineTo(x, cy + 4); ctx.lineTo(x - 4, cy); ctx.closePath(); ctx.fill(); this.hits.push({ x: x - 4, y: 0, w: 9, h: H, bookmark: b }); } ctx.restore(); } drawScopes(ctx, timeline, row, pxPerUs, textColor) { const { scopes, perDepth } = timeline; const M = this.metrics; const depthH = M.depthH; const startUs = this.startUs; const endUs = this.endUs; const highlightNameId = this.highlightName ? this.model.scopeNameIds.get(this.highlightName) : undefined; ctx.textBaseline = "middle"; ctx.textAlign = "left"; if (M.showLabels) { ctx.font = `${M.scopeFontPx}px -apple-system, Segoe UI, sans-serif`; } const rowTop = row.y + row.headerH; const maxDepth = Math.min(row.maxDepth, perDepth.length - 1); for (let depth = 0; depth <= maxDepth; depth++) { const indices = perDepth[depth]; if (!indices || indices.length === 0) continue; // Sibling scopes at the same depth never overlap, so their end // times are monotonic in begin order — a standard lower_bound // on (end >= startUs) correctly finds the first visible scope, // including outer-depth scopes whose begin is far before // the viewport start. let lo = 0; let hi = indices.length; while (lo < hi) { const mid = (lo + hi) >>> 1; const s = scopes[indices[mid]]; if (s[0] + s[1] < startUs) { lo = mid + 1; } else { hi = mid; } } const y = rowTop + depth * depthH; // Compact mode shrinks the bar to the row height; normal mode // leaves a 1px gap top and bottom for readability. const barTop = M.showLabels ? y + 1 : y; const barH = M.showLabels ? depthH - 2 : depthH; let rendered = 0; for (let j = lo; j < indices.length; j++) { const s = scopes[indices[j]]; if (s[0] > endUs) break; const beginUs = s[0]; const durationUs = s[1]; const nameId = s[2]; const mergeCount = s[4] || 0; ++rendered; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, durationUs * pxPerUs); const hue = ((nameId * 137.508) % 360 + 360) % 360; const isHighlighted = highlightNameId !== undefined && nameId === highlightNameId; if (mergeCount > 1) { // Merged scope — desaturated fill with dashed top indicator. ctx.fillStyle = isHighlighted ? `hsl(${hue.toFixed(0)}, 50%, 50%)` : `hsl(${hue.toFixed(0)}, 30%, 35%)`; ctx.fillRect(x, barTop, w, barH); if (M.showLabels) { ctx.strokeStyle = "rgba(255,255,255,0.25)"; ctx.lineWidth = 1; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(x, barTop + 0.5); ctx.lineTo(x + w, barTop + 0.5); ctx.stroke(); ctx.setLineDash([]); } } else { ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId); ctx.fillRect(x, barTop, w, barH); } // Draw the label pinned to the visible portion of the rect // so zooming into a long scope still shows its name. Skipped // in compact mode where the bar is too short for readable text. if (M.showLabels) { const visX = Math.max(x, 0); const visRight = Math.min(x + w, this.width); const visW = visRight - visX; if (visW > 30) { const name = this.model.scopeNames[nameId] || "?"; const maxChars = Math.floor((visW - 6) / 6); const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name; ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.save(); ctx.beginPath(); ctx.rect(visX + 3, y, visW - 6, depthH); ctx.clip(); ctx.fillText(shown, visX + 4, y + depthH / 2); ctx.restore(); } } this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s }); } } } drawSelectionOutline(ctx) { if (!this.selected || !this.selected.tuple) return; const s = this.selected.tuple; const beginUs = s[0]; const durationUs = s[1]; const depth = s[3]; const { rows } = this.layoutThreads(); const row = rows.find((r) => r.threadId === this.selected.threadId); if (!row) return; const M = this.metrics; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs()); const y = row.y + row.headerH + depth * M.depthH; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1); } drawViewportInfo() { const text = `${formatRange(this.startUs, this.endUs)} · ${(this.pxPerUs() * 1000).toFixed(2)} px/ms`; this.viewportInfoEl.textContent = text; } hitTest(clientX, clientY) { const rect = this.canvas.getBoundingClientRect(); const x = clientX - rect.left; const y = clientY - rect.top; for (let i = this.hits.length - 1; i >= 0; i--) { const h = this.hits[i]; if (x >= h.x && x < h.x + h.w && y >= h.y && y < h.y + h.h) { return h; } } return null; } onMouseDown(e) { if (e.button !== 0) return; this.panning = true; this.panMoved = false; this.panStartX = e.clientX; this.panStartY = e.clientY; this.panStartUs = this.startUs; this.panStartScrollY = this.scrollY; this.panRangeUs = this.endUs - this.startUs; } onMouseMove(e) { if (this.panning) { const dx = e.clientX - this.panStartX; const dy = e.clientY - this.panStartY; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) this.panMoved = true; const deltaUs = -dx / this.pxPerUs(); this.startUs = this.panStartUs + deltaUs; this.endUs = this.startUs + this.panRangeUs; this.scrollY = this.panStartScrollY - dy; this.checkViewportFetch(); this.requestDraw(); this.hideTooltip(); return; } const hit = this.hitTest(e.clientX, e.clientY); if (hit) { this.showTooltip(hit, e.clientX, e.clientY); } else { this.hideTooltip(); } } onMouseUp(e) { if (!this.panning) return; this.panning = false; if (!this.panMoved) { const hit = this.hitTest(e.clientX, e.clientY); if (hit) { this.selectScope(hit); } } } onWheel(e) { e.preventDefault(); // Shift+wheel (or horizontal wheel delta from a trackpad) scrolls // vertically without changing the zoom. if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) { const step = e.deltaX !== 0 ? e.deltaX : e.deltaY; this.scrollY += step; this.requestDraw(); return; } const rect = this.canvas.getBoundingClientRect(); const cursorX = e.clientX - rect.left; const cursorUs = this.usAtX(cursorX); const factor = e.deltaY > 0 ? 1.25 : 0.8; const newRange = Math.max(10, (this.endUs - this.startUs) * factor); this.startUs = cursorUs - (cursorX - PADDING_X) * (newRange / (this.width - PADDING_X * 2)); this.endUs = this.startUs + newRange; this.checkViewportFetch(); this.requestDraw(); } showTooltip(hit, clientX, clientY) { let title = ""; let meta = ""; if (hit.bookmark) { const b = hit.bookmark; title = b.text || "(unnamed bookmark)"; const loc = b.file ? `${b.file.split(/[\\/]/).pop()}:${b.line}` : ""; meta = `bookmark · ${formatTime(b.time_us)}${loc ? " · " + loc : ""}`; } else if (hit.region) { const r = hit.region; const dur = r.end_us - r.begin_us; title = r.name || "(unnamed region)"; meta = `region · ${formatTime(dur)} · start ${formatTime(r.begin_us)}${hit.regionCategory ? " · " + hit.regionCategory : ""}`; } else { const s = hit.tuple; title = this.model.scopeNames[s[2]] || "?"; const tm = this.threadMeta.get(hit.threadId); const threadName = (tm && tm.name) || `tid ${hit.threadId}`; meta = `${formatTime(s[1])} · depth ${s[3]} · ${threadName} · start ${formatTime(s[0])}`; if (s[4] > 1) { meta += ` · ${s[4]} merged`; } } this.tooltip.innerHTML = `
` + ``; this.tooltip.querySelector(".tt-name").textContent = title; this.tooltip.querySelector(".tt-meta").textContent = meta; const rect = this.canvas.getBoundingClientRect(); const tx = clientX - rect.left + 12; const ty = clientY - rect.top + 12; this.tooltip.style.left = `${tx}px`; this.tooltip.style.top = `${ty}px`; this.tooltip.hidden = false; } hideTooltip() { this.tooltip.hidden = true; } selectScope(hit) { this.selected = hit; if (hit.bookmark) { const b = hit.bookmark; this.selectionEl.innerHTML = `` + ``; this.selectionEl.querySelector(".selection-title").textContent = b.text || "(unnamed bookmark)"; this.selectionEl.querySelector("[data-k=time]").textContent = formatTime(b.time_us); this.selectionEl.querySelector("[data-k=src]").textContent = b.file ? `${b.file}:${b.line}` : ""; this.requestDraw(); return; } if (hit.region) { const r = hit.region; this.selectionEl.innerHTML = `` + ``; this.selectionEl.querySelector(".selection-title").textContent = r.name || "(unnamed region)"; this.selectionEl.querySelector("[data-k=dur]").textContent = formatTime(r.end_us - r.begin_us); this.selectionEl.querySelector("[data-k=begin]").textContent = formatTime(r.begin_us); this.selectionEl.querySelector("[data-k=end]").textContent = formatTime(r.end_us); this.selectionEl.querySelector("[data-k=cat]").textContent = hit.regionCategory || "\u2014"; this.requestDraw(); return; } const s = hit.tuple; const name = this.model.scopeNames[s[2]] || "?"; const meta = this.threadMeta.get(hit.threadId); const threadName = (meta && meta.name) || `tid ${hit.threadId}`; const mergedRow = s[4] > 1 ? `