diff options
Diffstat (limited to 'src/zen/frontend/html/timeline.js')
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 170 |
1 files changed, 111 insertions, 59 deletions
diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js index f463a8418..e0fd64181 100644 --- a/src/zen/frontend/html/timeline.js +++ b/src/zen/frontend/html/timeline.js @@ -2,16 +2,35 @@ // 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 +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. @@ -69,6 +88,8 @@ export class Timeline { 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. @@ -144,6 +165,17 @@ export class Timeline { 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(); @@ -152,10 +184,11 @@ export class Timeline { 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 += REGION_HEADER_H + cat.lane_count * REGION_LANE_H; + this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH; } if (this.regionsBlockH > 0) { this.regionsBlockH += REGIONS_GAP; @@ -406,6 +439,7 @@ export class Timeline { 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 : []; @@ -414,8 +448,8 @@ export class Timeline { 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 }); + 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. @@ -467,6 +501,7 @@ export class Timeline { 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; @@ -479,14 +514,14 @@ export class Timeline { 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.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 * DEPTH_H, W, DEPTH_H); + ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH); } const timeline = this.timelines.get(row.threadId); @@ -554,6 +589,7 @@ export class Timeline { 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(); @@ -566,13 +602,15 @@ export class Timeline { 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; + 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) { @@ -585,35 +623,35 @@ export class Timeline { const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs); if (w < MIN_RECT_W) continue; - const y = catY + r.depth * REGION_LANE_H; + const y = catY + r.depth * M.regionLaneH; ctx.fillStyle = regionFillColor(r.name); - ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2); + 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, REGION_LANE_H - 3); + 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 (visW > 24 && r.name) { + 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, REGION_LANE_H); + ctx.rect(visX + 3, y, visW - 6, M.regionLaneH); ctx.clip(); - ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2); + ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2); ctx.restore(); } - this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name }); + this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name }); } - catY += cat.lane_count * REGION_LANE_H; + catY += cat.lane_count * M.regionLaneH; } } @@ -660,6 +698,8 @@ export class Timeline { 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; @@ -669,7 +709,9 @@ export class Timeline { ctx.textBaseline = "middle"; ctx.textAlign = "left"; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + 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); @@ -695,7 +737,11 @@ export class Timeline { } } - const y = rowTop + depth * DEPTH_H; + 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]]; @@ -718,39 +764,44 @@ export class Timeline { 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([]); + 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, y + 1, w, DEPTH_H - 2); + 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. - 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(); + // 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: DEPTH_H - 2, threadId: row.threadId, tuple: s }); + this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s }); } } } @@ -764,12 +815,13 @@ export class Timeline { 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 * DEPTH_H; + 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, DEPTH_H - 1); + ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1); } drawViewportInfo() { |