|
|
@@ -81,7 +81,12 @@
|
|
|
<button id="range-apply">Apply range</button>
|
|
|
<button id="frame-prev">◀ prev</button>
|
|
|
<button id="frame-next">next ▶</button>
|
|
|
+ <button id="zoom-out" title="Zoom out (mouse wheel down)">−</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>
|