diff options
| author | Stefan Boberg <[email protected]> | 2026-04-23 18:16:57 +0200 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-04-23 18:16:57 +0200 |
| commit | 0232b991cd7d8e3a2114ea30e4591dd3e7b65c36 (patch) | |
| tree | 94730e7594fd09ae1fa820391ce311f6daf13905 /src/zen/frontend/html/timeline.js | |
| parent | Fix forward declaration order for s_GotSigWinch and SigWinchHandler (diff) | |
| parent | trace: declare Region event name fields as AnsiString (#1012) (diff) | |
| download | archived-zen-sb/zen-help.tar.xz archived-zen-sb/zen-help.zip | |
Merge branch 'main' into sb/zen-helpsb/zen-help
- Combine HelpCommand (this branch) with HistoryCommand (main) in zen CLI dispatcher
- Keep filter-aware TuiPickOne rewrite; adopt main's ASCII arrow glyphs in doc comment
Diffstat (limited to 'src/zen/frontend/html/timeline.js')
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 973 |
1 files changed, 973 insertions, 0 deletions
diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js new file mode 100644 index 000000000..f463a8418 --- /dev/null +++ b/src/zen/frontend/html/timeline.js @@ -0,0 +1,973 @@ +// 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 HEADER_H = 18; // thread name row height +const DEPTH_H = 16; // scope lane row height +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 REGION_LANE_H = 18; // region band row height +const REGION_HEADER_H = 16; // category header row height +const REGIONS_GAP = 6; // gap between the region rack and the first thread + +// 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.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(); + } + + setEnabledRegionCategories(indices) { + this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices); + this.recomputeRegionsBlockH(); + this.requestDraw(); + } + + recomputeRegionsBlockH() { + this.regionsBlockH = 0; + for (let i = 0; i < this.regionCategories.length; i++) { + if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue; + const cat = this.regionCategories[i]; + this.regionsBlockH += REGION_HEADER_H + cat.lane_count * REGION_LANE_H; + } + 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 }); + }); + 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 = HEADER_H + (maxDepth + 1) * DEPTH_H; + rows.push({ threadId, y, headerH: HEADER_H, 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"; + + 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 = "11px -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 * DEPTH_H, W, DEPTH_H); + } + + 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 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, REGION_HEADER_H); + 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 + REGION_HEADER_H / 2); + catY += REGION_HEADER_H; + + // 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 * REGION_LANE_H; + + ctx.fillStyle = regionFillColor(r.name); + ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2); + + ctx.strokeStyle = "rgba(255,255,255,0.2)"; + ctx.lineWidth = 1; + ctx.strokeRect(x + 0.5, y + 1.5, w - 1, REGION_LANE_H - 3); + + const visX = Math.max(x, 0); + const visRight = Math.min(x + w, this.width); + const visW = visRight - visX; + if (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, REGION_LANE_H); + ctx.clip(); + ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2); + ctx.restore(); + } + + this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name }); + } + + catY += cat.lane_count * REGION_LANE_H; + } + } + + 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 startUs = this.startUs; + const endUs = this.endUs; + + const highlightNameId = this.highlightName + ? this.model.scopeNameIds.get(this.highlightName) + : undefined; + + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.font = "11px -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 * DEPTH_H; + 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, y + 1, w, DEPTH_H - 2); + ctx.strokeStyle = "rgba(255,255,255,0.25)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(x, y + 1.5); + ctx.lineTo(x + w, y + 1.5); + ctx.stroke(); + ctx.setLineDash([]); + } else { + ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId); + ctx.fillRect(x, y + 1, w, DEPTH_H - 2); + } + + // Draw the label pinned to the visible portion of the rect + // so zooming into a long scope still shows its name. + 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, DEPTH_H); + ctx.clip(); + ctx.fillText(shown, visX + 4, y + DEPTH_H / 2); + ctx.restore(); + } + + this.hits.push({ x, y, w, h: DEPTH_H - 2, 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 x = this.xAtUs(beginUs); + const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs()); + const y = row.y + row.headerH + depth * DEPTH_H; + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1.5; + ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 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 = + `<div class="tt-name"></div>` + + `<div class="tt-meta"></div>`; + 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 = + `<div class="selection-title"></div>` + + `<div class="selection-meta">` + + `<div><span class="k">Kind:</span> <span class="v">bookmark</span></div>` + + `<div><span class="k">Time:</span> <span class="v" data-k="time"></span></div>` + + `<div><span class="k">Source:</span> <span class="v" data-k="src"></span></div>` + + `</div>`; + 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 = + `<div class="selection-title"></div>` + + `<div class="selection-meta">` + + `<div><span class="k">Kind:</span> <span class="v">region</span></div>` + + `<div><span class="k">Duration:</span> <span class="v" data-k="dur"></span></div>` + + `<div><span class="k">Begin:</span> <span class="v" data-k="begin"></span></div>` + + `<div><span class="k">End:</span> <span class="v" data-k="end"></span></div>` + + `<div><span class="k">Category:</span> <span class="v" data-k="cat"></span></div>` + + `</div>`; + 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 + ? `<div><span class="k">Merged:</span> <span class="v" data-k="merged"></span></div>` + : ""; + this.selectionEl.innerHTML = + `<div class="selection-title"></div>` + + `<div class="selection-meta">` + + `<div><span class="k">Duration:</span> <span class="v" data-k="dur"></span></div>` + + `<div><span class="k">Begin:</span> <span class="v" data-k="begin"></span></div>` + + `<div><span class="k">End:</span> <span class="v" data-k="end"></span></div>` + + `<div><span class="k">Depth:</span> <span class="v" data-k="depth"></span></div>` + + `<div><span class="k">Thread:</span> <span class="v" data-k="thread"></span></div>` + + mergedRow + + `</div>`; + this.selectionEl.querySelector(".selection-title").textContent = name; + this.selectionEl.querySelector("[data-k=dur]").textContent = formatTime(s[1]); + this.selectionEl.querySelector("[data-k=begin]").textContent = formatTime(s[0]); + this.selectionEl.querySelector("[data-k=end]").textContent = formatTime(s[0] + s[1]); + this.selectionEl.querySelector("[data-k=depth]").textContent = String(s[3]); + this.selectionEl.querySelector("[data-k=thread]").textContent = `${threadName} (${hit.threadId})`; + if (s[4] > 1) { + this.selectionEl.querySelector("[data-k=merged]").textContent = `${s[4]} scopes`; + } + this.requestDraw(); + this.onScopeSelect(name); + } +} |