Răsfoiți Sursa

Add frame timeline zoom and pan to web viewer

The timeline previously always drew the full recording, making it hard
to zero in on a frame in long captures. Add a view window separate from
the aggregate selection range:

- mouse wheel zooms around the cursor (down to a single frame)
- shift+drag and shift+wheel pan
- zoom in/out, zoom to selection and reset zoom buttons
- dynamic frame axis labels and a viewing-range indicator
- recompute the bar scale from the visible window
- scroll the view to follow a selection that moves off-screen
nosoop 1 zi în urmă
părinte
comite
4f5b96be2f
2 a modificat fișierele cu 142 adăugiri și 21 ștergeri
  1. 3 1
      README.md
  2. 139 20
      index.html

+ 3 - 1
README.md

@@ -31,7 +31,9 @@ drag & drop) a `.vprof` capture and the page parses it locally in a Web
 Worker — nothing is uploaded. It shows:
 
 * recording metadata and a warning when a capture contains no timing data;
-* a per-frame timeline (time, or call counts when times are all zero);
+* a per-frame timeline (time, or call counts when times are all zero) that
+  can be zoomed (mouse wheel, +/− buttons or *Zoom to selection*) and panned
+  (shift+drag or shift+wheel) to inspect individual frames;
 * a sortable, filterable, collapsible call tree with total/self/avg/max time,
   call counts and percentages;
 * budget-group and top-node breakdowns; and

+ 139 - 20
index.html

@@ -81,7 +81,12 @@
       <button id="range-apply">Apply range</button>
       <button id="frame-prev">&#9664; prev</button>
       <button id="frame-next">next &#9654;</button>
+      <button id="zoom-out" title="Zoom out (mouse wheel down)">&#8722;</button>
+      <button id="zoom-in" title="Zoom in (mouse wheel up)">+</button>
+      <button id="zoom-selection" title="Zoom the timeline to the selected frame range">Zoom to selection</button>
+      <button id="zoom-reset" title="Show all frames">Reset zoom</button>
       <span id="frame-label" class="muted"></span>
+      <span id="zoom-label" class="muted"></span>
       <span id="range-status" class="muted"></span>
     </div>
     <div id="timeline-wrap"><canvas id="timeline"></canvas></div>
@@ -746,6 +751,8 @@ function buildState(result, file) {
     frameCount: result.frameCount,
     rangeStart: 0,
     rangeEnd: Math.max(0, result.frameCount - 1),
+    viewStart: 0,
+    viewEnd: Math.max(0, result.frameCount - 1),
     metricTime: result.anyTime,
     sortKey: result.anyTime ? "total" : "name",
     sortDir: result.anyTime ? -1 : 1,
@@ -963,12 +970,17 @@ function timelineData() {
   return useCallsForTimeline() ? state.r.frameCalls : state.r.frameTotals;
 }
 function frameAt(clientX) {
+  var s = state;
   var c = $("timeline");
   var rect = c.getBoundingClientRect();
+  if (!rect.width) return s.viewStart;
   var x = clientX - rect.left;
-  var f = Math.floor((x / rect.width) * state.frameCount);
+  var count = s.viewEnd - s.viewStart + 1;
+  var f = s.viewStart + Math.floor((x / rect.width) * count);
+  if (f < s.viewStart) f = s.viewStart;
+  if (f > s.viewEnd) f = s.viewEnd;
   if (f < 0) f = 0;
-  if (f > state.frameCount - 1) f = state.frameCount - 1;
+  if (f > s.frameCount - 1) f = s.frameCount - 1;
   return f;
 }
 function drawTimeline() {
@@ -987,8 +999,10 @@ function drawTimeline() {
 
   var s = state;
   var data = timelineData();
+  var viewStart = s.viewStart, viewEnd = s.viewEnd;
+  var viewCount = Math.max(1, viewEnd - viewStart + 1);
   var max = 0;
-  for (var i = 0; i < s.frameCount; i++) if (data[i] > max) max = data[i];
+  for (var i = viewStart; i <= viewEnd; i++) if (data[i] > max) max = data[i];
 
   var selA = s.rangeStart, selB = s.rangeEnd;
   if (drag && drag.active) {
@@ -998,35 +1012,98 @@ function drawTimeline() {
 
   var padTop = 4, padBottom = 16;
   var plotH = h - padTop - padBottom;
-  var bw = s.frameCount ? (w / s.frameCount) : w;
+  var bw = w / viewCount;
 
-  for (var f = 0; f < s.frameCount; f++) {
-    var v = data[f];
+  for (var f = viewStart; f <= viewEnd; f++) {
+    var v = data[f] || 0;
     var bh = max ? (v / max) * plotH : 0;
-    var x = f * bw;
+    var x = (f - viewStart) * bw;
     var inSel = (f >= selA && f <= selB);
     ctx.fillStyle = inSel ? "#2b6cb0" : "#b9c6d6";
     ctx.fillRect(x, padTop + plotH - bh, Math.max(1, bw - 0.25), bh);
   }
 
-  // selection background edges
+  // selection edges (only when they fall inside the current view)
   ctx.strokeStyle = "#e0a800";
   ctx.lineWidth = 1;
-  if (selA > 0) { var xa = selA * bw; ctx.beginPath(); ctx.moveTo(xa, padTop); ctx.lineTo(xa, padTop + plotH); ctx.stroke(); }
-  if (selB < s.frameCount - 1) { var xb = (selB + 1) * bw; ctx.beginPath(); ctx.moveTo(xb, padTop); ctx.lineTo(xb, padTop + plotH); ctx.stroke(); }
+  if (selA > viewStart) { var xa = (selA - viewStart) * bw; ctx.beginPath(); ctx.moveTo(xa, padTop); ctx.lineTo(xa, padTop + plotH); ctx.stroke(); }
+  if (selB < viewEnd) { var xb = (selB - viewStart + 1) * bw; ctx.beginPath(); ctx.moveTo(xb, padTop); ctx.lineTo(xb, padTop + plotH); ctx.stroke(); }
 
   ctx.fillStyle = "#333";
   ctx.font = "11px system-ui, sans-serif";
   var unit = useCallsForTimeline() ? "calls" : "time";
-  ctx.fillText("max " + (useCallsForTimeline() ? fmtInt(max) : fmtUs(max)) + " per frame (" + unit + ")", 4, h - 4);
-  ctx.fillText("frame 0", 4, h - 4 - 0);
-  var lastLabel = "frame " + (s.frameCount - 1);
-  ctx.fillText(lastLabel, Math.max(4, w - ctx.measureText(lastLabel).width - 4), h - 4);
-
-  $("timeline-caption").textContent = useCallsForTimeline()
-    ? "Showing per-frame total call counts (no timing data available). Drag to select a frame range."
-    : "Showing per-frame total microseconds. Drag to select a frame range.";
+  ctx.fillText("max " + (useCallsForTimeline() ? fmtInt(max) : fmtUs(max)) + " per frame (" + unit + ")", 4, 11);
+  var firstLabel = "frame " + viewStart;
+  ctx.fillText(firstLabel, 4, h - 5);
+  var lastLabel = "frame " + viewEnd;
+  ctx.fillText(lastLabel, Math.max(4, w - ctx.measureText(lastLabel).width - 4), h - 5);
+
+  var hint = useCallsForTimeline()
+    ? "Showing per-frame total call counts (no timing data available)."
+    : "Showing per-frame total microseconds.";
+  hint += " Drag to select a range, scroll to zoom, shift+drag to pan.";
+  $("timeline-caption").textContent = hint;
   $("frame-label").textContent = "selected frames " + selA + "-" + selB;
+  updateZoomLabel();
+}
+
+/* ---- timeline zoom / pan ---- */
+// setView clamps a requested frame window to the recording and stores it.
+function setView(start, end) {
+  var s = state;
+  var total = s.frameCount;
+  if (total < 1) { s.viewStart = 0; s.viewEnd = 0; return; }
+  var count = end - start + 1;
+  if (count > total) count = total;
+  if (count < 1) count = 1;
+  if (start < 0) start = 0;
+  if (start + count - 1 > total - 1) start = total - count;
+  s.viewStart = start;
+  s.viewEnd = start + count - 1;
+}
+
+// factor > 1 zooms in (fewer visible frames); centerFrame keeps the frame
+// under the cursor fixed when given.
+function zoomView(factor, centerFrame) {
+  var s = state;
+  var count = s.viewEnd - s.viewStart + 1;
+  var newCount = Math.round(count / factor);
+  if (newCount < 1) newCount = 1;
+  if (newCount > s.frameCount) newCount = s.frameCount;
+  if (newCount === count) return;
+  var anchor = (typeof centerFrame === "number") ? centerFrame : (s.viewStart + s.viewEnd) / 2;
+  var rel = count > 1 ? (anchor - s.viewStart) / (count - 1) : 0.5;
+  var newStart = Math.round(anchor - rel * (newCount - 1));
+  setView(newStart, newStart + newCount - 1);
+  drawTimeline();
+}
+
+function panView(deltaFrames) {
+  var s = state;
+  var count = s.viewEnd - s.viewStart + 1;
+  if (count >= s.frameCount) return;
+  setView(s.viewStart + deltaFrames, s.viewEnd + deltaFrames);
+  drawTimeline();
+}
+
+// If a selection moves completely outside the current view, scroll to it.
+function revealSelection(a, b) {
+  var s = state;
+  if (b >= s.viewStart && a <= s.viewEnd) return;
+  var count = s.viewEnd - s.viewStart + 1;
+  var start = a;
+  if (start + count - 1 > s.frameCount - 1) start = s.frameCount - count;
+  setView(start, start + count - 1);
+}
+
+function updateZoomLabel() {
+  var el = $("zoom-label");
+  if (!el || !state) return;
+  var s = state;
+  var full = s.viewStart <= 0 && s.viewEnd >= s.frameCount - 1;
+  el.textContent = full
+    ? ""
+    : "viewing frames " + s.viewStart + "\u2013" + s.viewEnd + " of " + fmtInt(s.frameCount);
 }
 function applyRange(a, b) {
   var s = state;
@@ -1035,6 +1112,7 @@ function applyRange(a, b) {
   if (a > b) { var t = a; a = b; b = t; }
   s.rangeStart = a; s.rangeEnd = b;
   updateRangeInputs();
+  revealSelection(a, b);
   refreshAggregate();
 }
 function updateRangeInputs() {
@@ -1282,29 +1360,70 @@ $("filter").addEventListener("input", function (e) {
   filterTimer = setTimeout(function () { state.filter = e.target.value.trim(); renderTree(); }, 120);
 });
 
-/* timeline drag selection */
+/* timeline interaction: drag selects, shift+drag pans, wheel zooms */
 (function () {
   var c = $("timeline");
+  var pan = null;
+
   c.addEventListener("mousedown", function (e) {
-    if (!state || !canQueryRanges()) return;
+    if (!state) return;
     e.preventDefault();
+    if (e.shiftKey) {
+      pan = { startX: e.clientX, startView: state.viewStart, count: state.viewEnd - state.viewStart + 1 };
+      return;
+    }
+    if (!canQueryRanges()) return;
     drag = { active: true, start: frameAt(e.clientX), end: frameAt(e.clientX) };
     drawTimeline();
   });
   window.addEventListener("mousemove", function (e) {
+    if (pan) {
+      var rect = c.getBoundingClientRect();
+      if (!rect.width) return;
+      var delta = Math.round((e.clientX - pan.startX) / rect.width * pan.count);
+      setView(pan.startView - delta, pan.startView - delta + pan.count - 1);
+      drawTimeline();
+      return;
+    }
     if (!drag || !drag.active) return;
     drag.end = frameAt(e.clientX);
     drawTimeline();
   });
   window.addEventListener("mouseup", function (e) {
+    if (pan) { pan = null; return; }
     if (!drag || !drag.active) return;
     drag.end = frameAt(e.clientX);
     var a = drag.start, b = drag.end;
     drag = null;
     applyRange(a, b);
   });
+  c.addEventListener("wheel", function (e) {
+    if (!state) return;
+    e.preventDefault();
+    if (e.shiftKey) {
+      var count = state.viewEnd - state.viewStart + 1;
+      var rect = c.getBoundingClientRect();
+      var step = Math.max(1, Math.round(((e.deltaY || e.deltaX) / (rect.height || 1)) * count));
+      panView(e.deltaY > 0 ? step : -step);
+      return;
+    }
+    zoomView(e.deltaY < 0 ? 1.3 : 1 / 1.3, frameAt(e.clientX));
+  }, { passive: false });
   window.addEventListener("resize", function () { if (state) drawTimeline(); });
 })();
+
+$("zoom-in").onclick = function () { if (state) zoomView(1.5); };
+$("zoom-out").onclick = function () { if (state) zoomView(1 / 1.5); };
+$("zoom-selection").onclick = function () {
+  if (!state) return;
+  setView(state.rangeStart, state.rangeEnd);
+  drawTimeline();
+};
+$("zoom-reset").onclick = function () {
+  if (!state) return;
+  setView(0, state.frameCount - 1);
+  drawTimeline();
+};
 </script>
 </body>
 </html>