aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend/html/timeline.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/frontend/html/timeline.js')
-rw-r--r--src/zen/frontend/html/timeline.js170
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() {