aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend/html/timeline.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-04-23 18:16:57 +0200
committerStefan Boberg <[email protected]>2026-04-23 18:16:57 +0200
commit0232b991cd7d8e3a2114ea30e4591dd3e7b65c36 (patch)
tree94730e7594fd09ae1fa820391ce311f6daf13905 /src/zen/frontend/html/timeline.js
parentFix forward declaration order for s_GotSigWinch and SigWinchHandler (diff)
parenttrace: declare Region event name fields as AnsiString (#1012) (diff)
downloadarchived-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.js973
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);
+ }
+}