|
|
@@ -0,0 +1,993 @@
|
|
|
+<!DOCTYPE html>
|
|
|
+<html lang="en">
|
|
|
+<head>
|
|
|
+<meta charset="utf-8">
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
+<title>Source VProf Viewer</title>
|
|
|
+<style>
|
|
|
+ /* Layout only. A visual design pass will replace this. */
|
|
|
+ * { box-sizing: border-box; }
|
|
|
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
|
+ font-size: 13px; margin: 0; color: #111; background: #fafafa; }
|
|
|
+ header { padding: 8px 12px; background: #1a202c; color: #fff; }
|
|
|
+ header h1 { font-size: 16px; margin: 0 0 6px 0; }
|
|
|
+ header .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
|
+ header label { font-size: 12px; }
|
|
|
+ main { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
|
|
|
+ section { background: #fff; border: 1px solid #ddd; padding: 8px; }
|
|
|
+ h2 { font-size: 14px; margin: 0 0 6px 0; }
|
|
|
+ h3 { font-size: 13px; margin: 10px 0 4px 0; }
|
|
|
+ table { border-collapse: collapse; width: 100%; }
|
|
|
+ th, td { border: 1px solid #ddd; padding: 2px 6px; text-align: left;
|
|
|
+ font-size: 12px; vertical-align: top; }
|
|
|
+ th { background: #f0f0f0; cursor: pointer; white-space: nowrap; }
|
|
|
+ th.nosort { cursor: default; }
|
|
|
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
|
+ .muted { color: #666; }
|
|
|
+ .warn { background: #fff5e6; border: 1px solid #e0a800; padding: 6px 8px; }
|
|
|
+ .notice { background: #e8f0fe; border: 1px solid #6b9bd2; padding: 6px 8px; }
|
|
|
+ .summary-grid { display: flex; flex-wrap: wrap; gap: 12px 24px; }
|
|
|
+ .summary-grid div { min-width: 120px; }
|
|
|
+ .summary-grid .k { color: #666; font-size: 11px; }
|
|
|
+ .summary-grid .v { font-size: 14px; font-weight: 600; }
|
|
|
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
|
|
+ .controls input[type=number] { width: 90px; }
|
|
|
+ #timeline-wrap { width: 100%; }
|
|
|
+ #timeline { display: block; width: 100%; height: 120px; background: #fff;
|
|
|
+ border: 1px solid #ddd; cursor: crosshair; }
|
|
|
+ .grid2 { display: grid; grid-template-columns: minmax(0,1fr) 360px; gap: 12px; }
|
|
|
+ @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
|
|
|
+ .toggle { cursor: pointer; user-select: none; display: inline-block; width: 12px; }
|
|
|
+ .tree-name { white-space: nowrap; }
|
|
|
+ .bar { display: inline-block; height: 8px; background: #6b9bd2; vertical-align: middle; }
|
|
|
+ .progress { height: 6px; background: #ddd; width: 200px; }
|
|
|
+ .progress > div { height: 100%; background: #2b6cb0; width: 0%; }
|
|
|
+ .scroll { max-height: 520px; overflow: auto; }
|
|
|
+ .hidden { display: none; }
|
|
|
+ footer { padding: 8px 12px; color: #666; font-size: 11px; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+
|
|
|
+<header>
|
|
|
+ <h1>Source VProf Viewer</h1>
|
|
|
+ <div class="row">
|
|
|
+ <label>Choose a .vprof file: <input type="file" id="file"></label>
|
|
|
+ <span id="drop-hint" class="muted">or drag & drop it onto this page</span>
|
|
|
+ <div id="progress-wrap" class="hidden">
|
|
|
+ <div class="progress"><div id="progress-bar"></div></div>
|
|
|
+ <span id="status" class="muted"></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</header>
|
|
|
+
|
|
|
+<main>
|
|
|
+ <section id="error-box" class="warn hidden"></section>
|
|
|
+
|
|
|
+ <section id="summary-section" class="hidden">
|
|
|
+ <h2>Recording summary</h2>
|
|
|
+ <div id="notices"></div>
|
|
|
+ <div id="summary" class="summary-grid"></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section id="timeline-section" class="hidden">
|
|
|
+ <h2>Frame timeline</h2>
|
|
|
+ <div class="controls">
|
|
|
+ <button id="range-all">All frames</button>
|
|
|
+ <label>From frame <input type="number" id="range-start" min="0" step="1"></label>
|
|
|
+ <label>to <input type="number" id="range-end" min="0" step="1"></label>
|
|
|
+ <button id="range-apply">Apply range</button>
|
|
|
+ <button id="frame-prev">◀ prev</button>
|
|
|
+ <button id="frame-next">next ▶</button>
|
|
|
+ <span id="frame-label" class="muted"></span>
|
|
|
+ </div>
|
|
|
+ <div id="timeline-wrap"><canvas id="timeline"></canvas></div>
|
|
|
+ <div id="timeline-caption" class="muted"></div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div class="grid2">
|
|
|
+ <section id="tree-section" class="hidden">
|
|
|
+ <h2>Call tree</h2>
|
|
|
+ <div class="controls">
|
|
|
+ <label>Filter <input type="search" id="filter" placeholder="name contains..."></label>
|
|
|
+ <button id="expand-all">Expand all</button>
|
|
|
+ <button id="collapse-all">Collapse all</button>
|
|
|
+ <span id="tree-count" class="muted"></span>
|
|
|
+ </div>
|
|
|
+ <div class="scroll">
|
|
|
+ <table id="tree-table">
|
|
|
+ <thead><tr id="tree-head"></tr></thead>
|
|
|
+ <tbody id="tree-body"></tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <section id="budget-section" class="hidden">
|
|
|
+ <h2>Budget groups</h2>
|
|
|
+ <div class="scroll">
|
|
|
+ <table id="budget-table">
|
|
|
+ <thead><tr><th class="nosort">Group</th><th class="nosort num">Calls</th>
|
|
|
+ <th class="nosort num">Total</th><th class="nosort num">%</th></tr></thead>
|
|
|
+ <tbody id="budget-body"></tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section id="top-section" class="hidden">
|
|
|
+ <h2>Top nodes</h2>
|
|
|
+ <div class="scroll">
|
|
|
+ <table id="top-table">
|
|
|
+ <thead><tr><th class="nosort">Scope</th><th class="nosort num">Total</th>
|
|
|
+ <th class="nosort num">Self</th><th class="nosort num">Calls</th></tr></thead>
|
|
|
+ <tbody id="top-body"></tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</main>
|
|
|
+
|
|
|
+<footer>
|
|
|
+ VProf binary format documentation: see <code>VPROF_FORMAT.md</code> in the repository.
|
|
|
+</footer>
|
|
|
+
|
|
|
+<!-- ===================== Parsing worker (inline, self-contained) ===================== -->
|
|
|
+<script id="worker-src" type="text/js-worker">
|
|
|
+"use strict";
|
|
|
+
|
|
|
+// The 31 budget groups registered by CVProfile::CVProfile(), in fixed ID order.
|
|
|
+var DEFAULT_BUDGET_GROUPS = [
|
|
|
+ ["Unaccounted", 6],
|
|
|
+ ["World Rendering", 1],
|
|
|
+ ["Displacement_Rendering", 1],
|
|
|
+ ["Game", 6],
|
|
|
+ ["Player", 6],
|
|
|
+ ["NPCs", 6],
|
|
|
+ ["Server Animation", 6],
|
|
|
+ ["Client_Animation", 1],
|
|
|
+ ["Physics", 6],
|
|
|
+ ["Static_Prop_Rendering", 1],
|
|
|
+ ["Other_Model_Rendering", 1],
|
|
|
+ ["Fast Path Model Rendering", 1],
|
|
|
+ ["Light_Cache", 1],
|
|
|
+ ["Brush_Model_Rendering", 1],
|
|
|
+ ["Shadow_Rendering", 1],
|
|
|
+ ["Detail_Prop_Rendering", 1],
|
|
|
+ ["Particle/Effect_Rendering", 1],
|
|
|
+ ["Ropes", 1],
|
|
|
+ ["Dynamic_Light_Rendering", 1],
|
|
|
+ ["Networking", 6],
|
|
|
+ ["Sound", 6],
|
|
|
+ ["VGUI", 6],
|
|
|
+ ["FileSystem", 6],
|
|
|
+ ["Prediction", 1],
|
|
|
+ ["Interpolation", 1],
|
|
|
+ ["Swap_Buffers", 1],
|
|
|
+ ["Occlusion", 1],
|
|
|
+ ["Overlays", 1],
|
|
|
+ ["Tools", 5],
|
|
|
+ ["Texture_Cache", 1],
|
|
|
+ ["Replay", 2]
|
|
|
+];
|
|
|
+
|
|
|
+var TOKEN_START_FRAME = 0;
|
|
|
+var TOKEN_ADD_NODE = 1;
|
|
|
+var TOKEN_ADD_BUDGET_GROUP = 2;
|
|
|
+var TOKEN_TIMINGS = 3;
|
|
|
+var TOKEN_END_OF_FRAME = 4;
|
|
|
+var TOKEN_FILE_FINISHED = 5;
|
|
|
+
|
|
|
+self.onmessage = function (e) {
|
|
|
+ var msg = e.data;
|
|
|
+ if (!msg || msg.cmd !== "parse") return;
|
|
|
+ try {
|
|
|
+ var out = parseVprof(msg.buffer, msg.opts || {});
|
|
|
+ self.postMessage({ type: "done", result: out.result }, out.transfer);
|
|
|
+ } catch (err) {
|
|
|
+ self.postMessage({ type: "error", message: (err && err.message) || String(err) });
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function parseVprof(buffer, opts) {
|
|
|
+ var t0 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
|
|
|
+ var dv = new DataView(buffer);
|
|
|
+ var totalBytes = buffer.byteLength;
|
|
|
+ var pos = 0;
|
|
|
+ var decoder = new TextDecoder("utf-8");
|
|
|
+
|
|
|
+ function u8() { return dv.getUint8(pos++); }
|
|
|
+ function u16() { var v = dv.getUint16(pos, true); pos += 2; return v; }
|
|
|
+ function u32() { var v = dv.getUint32(pos, true); pos += 4; return v; }
|
|
|
+ function i32() { var v = dv.getInt32(pos, true); pos += 4; return v; }
|
|
|
+ function cstr() {
|
|
|
+ var start = pos;
|
|
|
+ while (dv.getUint8(pos) !== 0) pos++;
|
|
|
+ var s = decoder.decode(new Uint8Array(buffer, start, pos - start));
|
|
|
+ pos++;
|
|
|
+ return s;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (totalBytes < 8) throw new Error("File is too small to be a .vprof recording.");
|
|
|
+
|
|
|
+ var version = i32();
|
|
|
+ var rootNodeId = i32();
|
|
|
+
|
|
|
+ // ---- Budget groups (seed the 31 defaults so sparse recordings still resolve) ----
|
|
|
+ var budgetGroups = [];
|
|
|
+ var groupIdByName = Object.create(null);
|
|
|
+ for (var i = 0; i < DEFAULT_BUDGET_GROUPS.length; i++) {
|
|
|
+ var g = DEFAULT_BUDGET_GROUPS[i];
|
|
|
+ budgetGroups.push({ id: i, name: g[0], flags: g[1], isDefault: true });
|
|
|
+ groupIdByName[g[0]] = i;
|
|
|
+ }
|
|
|
+ function addBudgetGroup(name, flags) {
|
|
|
+ var id = groupIdByName[name];
|
|
|
+ if (id !== undefined) { budgetGroups[id].flags = flags; return id; }
|
|
|
+ id = budgetGroups.length;
|
|
|
+ budgetGroups.push({ id: id, name: name, flags: flags, isDefault: false });
|
|
|
+ groupIdByName[name] = id;
|
|
|
+ return id;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Nodes (sparse array indexed by unique node id) ----
|
|
|
+ var nodesById = [];
|
|
|
+ function makeNode(id, name, parentId, budgetGroupId) {
|
|
|
+ var n = { id: id, name: name, parentId: parentId, budgetGroupId: budgetGroupId, children: [] };
|
|
|
+ nodesById[id] = n;
|
|
|
+ return n;
|
|
|
+ }
|
|
|
+ makeNode(rootNodeId, "Root", null, 0);
|
|
|
+
|
|
|
+ // ---- Aggregates (sparse array indexed by unique node id) ----
|
|
|
+ var agg = [];
|
|
|
+ function aggFor(id) {
|
|
|
+ var a = agg[id];
|
|
|
+ if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
|
|
|
+ return a;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Per-frame storage budget ----
|
|
|
+ var maxSamples = (opts.maxSamples && opts.maxSamples > 0) ? opts.maxSamples : 4000000;
|
|
|
+ var allowSamples = opts.noSamples !== true;
|
|
|
+ var storing = allowSamples;
|
|
|
+ var overflow = false;
|
|
|
+ var totalSamples = 0;
|
|
|
+ var samples = [];
|
|
|
+
|
|
|
+ var frameTicks = [];
|
|
|
+ var frameTotals = [];
|
|
|
+ var frameCalls = [];
|
|
|
+ var addedNodesByFrame = [];
|
|
|
+
|
|
|
+ var anyTime = false;
|
|
|
+ var anyCalls = false;
|
|
|
+
|
|
|
+ // Outer variables reused by readTimings (avoids allocating a closure per frame)
|
|
|
+ var curSamples = null;
|
|
|
+ var frameTotalUs = 0;
|
|
|
+ var frameTotalCalls = 0;
|
|
|
+
|
|
|
+ function readTimings(nodeId) {
|
|
|
+ var callsToken = u8();
|
|
|
+ var calls;
|
|
|
+ if (callsToken === 255) calls = u16(); else calls = callsToken;
|
|
|
+
|
|
|
+ var timeUnits = u16();
|
|
|
+ var us;
|
|
|
+ if (timeUnits === 0xFFFF) us = u32() * 4; else us = timeUnits * 4;
|
|
|
+
|
|
|
+ if (calls > 0) anyCalls = true;
|
|
|
+ if (us > 0) anyTime = true;
|
|
|
+ totalSamples++;
|
|
|
+
|
|
|
+ if (storing) {
|
|
|
+ curSamples.ids.push(nodeId);
|
|
|
+ curSamples.calls.push(calls);
|
|
|
+ curSamples.us.push(us);
|
|
|
+ if (totalSamples > maxSamples) {
|
|
|
+ // Too large to keep every frame; fall back to whole-recording aggregate.
|
|
|
+ storing = false;
|
|
|
+ overflow = true;
|
|
|
+ samples.length = 0;
|
|
|
+ curSamples = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ frameTotalUs += us;
|
|
|
+ frameTotalCalls += calls;
|
|
|
+
|
|
|
+ var a = aggFor(nodeId);
|
|
|
+ a.calls += calls;
|
|
|
+ a.us += us;
|
|
|
+ if (us > a.maxUs) a.maxUs = us;
|
|
|
+ a.frames++;
|
|
|
+
|
|
|
+ var kids = nodesById[nodeId].children;
|
|
|
+ for (var k = 0; k < kids.length; k++) readTimings(kids[k]);
|
|
|
+ }
|
|
|
+
|
|
|
+ var frameCount = 0;
|
|
|
+ var progressAt = 0;
|
|
|
+ var totalFramesApprox = -1;
|
|
|
+
|
|
|
+ while (pos < totalBytes) {
|
|
|
+ var token = u8();
|
|
|
+ if (token === TOKEN_FILE_FINISHED) break;
|
|
|
+ if (token !== TOKEN_START_FRAME) {
|
|
|
+ throw new Error("Expected start-of-frame token at offset " + (pos - 1) + ", got " + token + ".");
|
|
|
+ }
|
|
|
+ var tick = i32();
|
|
|
+ frameTicks.push(tick);
|
|
|
+
|
|
|
+ frameTotalUs = 0;
|
|
|
+ frameTotalCalls = 0;
|
|
|
+ curSamples = storing ? { ids: [], calls: [], us: [] } : null;
|
|
|
+ var added = [];
|
|
|
+
|
|
|
+ while (true) {
|
|
|
+ var inner = u8();
|
|
|
+ if (inner === TOKEN_END_OF_FRAME) break;
|
|
|
+ if (inner === TOKEN_ADD_BUDGET_GROUP) {
|
|
|
+ var bgName = cstr();
|
|
|
+ var bgFlags = i32();
|
|
|
+ addBudgetGroup(bgName, bgFlags);
|
|
|
+ } else if (inner === TOKEN_ADD_NODE) {
|
|
|
+ var parentId = i32();
|
|
|
+ var nodeName = cstr();
|
|
|
+ var bgId = i32();
|
|
|
+ var nodeId = i32();
|
|
|
+ var parent = nodesById[parentId];
|
|
|
+ if (!parent) throw new Error("AddNode references unknown parent " + parentId + " at offset " + (pos - 4) + ".");
|
|
|
+ if (nodesById[nodeId]) throw new Error("AddNode re-declares node " + nodeId + " at offset " + (pos - 4) + ".");
|
|
|
+ makeNode(nodeId, nodeName, parentId, bgId);
|
|
|
+ parent.children.unshift(nodeId); // CVProfNode::GetSubNode() prepends
|
|
|
+ added.push(nodeId);
|
|
|
+ } else if (inner === TOKEN_TIMINGS) {
|
|
|
+ readTimings(rootNodeId);
|
|
|
+ } else {
|
|
|
+ throw new Error("Unexpected token " + inner + " inside frame " + frameCount + " at offset " + (pos - 1) + ".");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (storing && curSamples) samples.push(curSamples);
|
|
|
+ frameTotals.push(frameTotalUs);
|
|
|
+ frameCalls.push(frameTotalCalls);
|
|
|
+ addedNodesByFrame.push(added);
|
|
|
+ frameCount++;
|
|
|
+
|
|
|
+ if (frameCount - progressAt >= 500) {
|
|
|
+ progressAt = frameCount;
|
|
|
+ self.postMessage({ type: "progress", frames: frameCount, pos: pos, total: totalBytes });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- Compact and collect transferables ----
|
|
|
+ var transfer = [];
|
|
|
+ var frameTicksOut = Int32Array.from(frameTicks);
|
|
|
+ var frameTotalsOut = Float64Array.from(frameTotals);
|
|
|
+ var frameCallsOut = Uint32Array.from(frameCalls);
|
|
|
+ transfer.push(frameTicksOut.buffer, frameTotalsOut.buffer, frameCallsOut.buffer);
|
|
|
+
|
|
|
+ var addedOut = [];
|
|
|
+ for (var f = 0; f < addedNodesByFrame.length; f++) {
|
|
|
+ var arr = Int32Array.from(addedNodesByFrame[f]);
|
|
|
+ transfer.push(arr.buffer);
|
|
|
+ addedOut.push(arr);
|
|
|
+ }
|
|
|
+
|
|
|
+ var samplesOut = null;
|
|
|
+ if (storing) {
|
|
|
+ samplesOut = [];
|
|
|
+ for (var s = 0; s < samples.length; s++) {
|
|
|
+ var fr = samples[s];
|
|
|
+ var ids = Int32Array.from(fr.ids);
|
|
|
+ var callsArr = Uint32Array.from(fr.calls);
|
|
|
+ var usArr = Float64Array.from(fr.us);
|
|
|
+ transfer.push(ids.buffer, callsArr.buffer, usArr.buffer);
|
|
|
+ samplesOut.push({ ids: ids, calls: callsArr, us: usArr });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var t1 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
|
|
|
+
|
|
|
+ return {
|
|
|
+ result: {
|
|
|
+ version: version,
|
|
|
+ rootNodeId: rootNodeId,
|
|
|
+ budgetGroups: budgetGroups,
|
|
|
+ nodes: nodesById,
|
|
|
+ aggregates: agg,
|
|
|
+ frameCount: frameCount,
|
|
|
+ frameTicks: frameTicksOut,
|
|
|
+ frameTotals: frameTotalsOut,
|
|
|
+ frameCalls: frameCallsOut,
|
|
|
+ addedNodesByFrame: addedOut,
|
|
|
+ samples: samplesOut,
|
|
|
+ samplesStored: storing,
|
|
|
+ samplesOverflow: overflow,
|
|
|
+ totalSamples: totalSamples,
|
|
|
+ anyTime: anyTime,
|
|
|
+ anyCalls: anyCalls,
|
|
|
+ parseMs: t1 - t0
|
|
|
+ },
|
|
|
+ transfer: transfer
|
|
|
+ };
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<!-- ===================== UI ===================== -->
|
|
|
+<script>
|
|
|
+"use strict";
|
|
|
+
|
|
|
+/* ------------------------------- helpers ------------------------------- */
|
|
|
+function $(id) { return document.getElementById(id); }
|
|
|
+function esc(s) {
|
|
|
+ return String(s).replace(/[&<>"']/g, function (c) {
|
|
|
+ return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c];
|
|
|
+ });
|
|
|
+}
|
|
|
+function fmtUs(us) {
|
|
|
+ if (!us) return "0";
|
|
|
+ var ms = us / 1000;
|
|
|
+ if (Math.abs(ms) >= 1000) return (ms / 1000).toFixed(3) + " s";
|
|
|
+ if (Math.abs(ms) >= 1) return ms.toFixed(3) + " ms";
|
|
|
+ return us.toFixed(1) + " \u00b5s";
|
|
|
+}
|
|
|
+function fmtInt(n) {
|
|
|
+ return (n === undefined || n === null) ? "0" : Number(n).toLocaleString();
|
|
|
+}
|
|
|
+function fmtBytes(n) {
|
|
|
+ if (n < 1024) return n + " B";
|
|
|
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
|
|
|
+ if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(1) + " MiB";
|
|
|
+ return (n / 1073741824).toFixed(2) + " GiB";
|
|
|
+}
|
|
|
+function flagsDecoded(flags) {
|
|
|
+ var out = [];
|
|
|
+ if (flags & 1) out.push("CLIENT");
|
|
|
+ if (flags & 2) out.push("SERVER");
|
|
|
+ if (flags & 4) out.push("OTHER");
|
|
|
+ if (flags & 32768) out.push("HIDDEN");
|
|
|
+ return out;
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------- state ------------------------------- */
|
|
|
+var state = null;
|
|
|
+
|
|
|
+/* ------------------------------- loading ------------------------------- */
|
|
|
+var worker = null;
|
|
|
+var lastFile = null;
|
|
|
+
|
|
|
+function setError(msg) {
|
|
|
+ var box = $("error-box");
|
|
|
+ if (!msg) { box.classList.add("hidden"); box.textContent = ""; return; }
|
|
|
+ box.classList.remove("hidden");
|
|
|
+ box.textContent = msg;
|
|
|
+}
|
|
|
+
|
|
|
+function startParse(file) {
|
|
|
+ lastFile = file;
|
|
|
+ setError("");
|
|
|
+ $("progress-wrap").classList.remove("hidden");
|
|
|
+ $("progress-bar").style.width = "0%";
|
|
|
+ $("status").textContent = "Reading " + file.name + " (" + fmtBytes(file.size) + ")...";
|
|
|
+
|
|
|
+ file.arrayBuffer().then(function (buf) {
|
|
|
+ if (worker) { worker.terminate(); worker = null; }
|
|
|
+ var src = $("worker-src").textContent;
|
|
|
+ var url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
|
|
|
+ worker = new Worker(url);
|
|
|
+ worker.onmessage = onWorkerMessage;
|
|
|
+ worker.onerror = function (e) {
|
|
|
+ setError("Worker error: " + (e.message || "unknown"));
|
|
|
+ };
|
|
|
+ $("status").textContent = "Parsing...";
|
|
|
+ worker.postMessage({ cmd: "parse", buffer: buf, opts: { maxSamples: 4000000 } }, [buf]);
|
|
|
+ }).catch(function (err) {
|
|
|
+ setError("Could not read file: " + err);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function onWorkerMessage(e) {
|
|
|
+ var msg = e.data;
|
|
|
+ if (msg.type === "progress") {
|
|
|
+ var pct = msg.total ? Math.floor(100 * msg.pos / msg.total) : 0;
|
|
|
+ $("progress-bar").style.width = pct + "%";
|
|
|
+ $("status").textContent = "Parsing... " + fmtInt(msg.frames) + " frames (" + pct + "%)";
|
|
|
+ } else if (msg.type === "error") {
|
|
|
+ setError("Parse error: " + msg.message);
|
|
|
+ $("status").textContent = "Failed.";
|
|
|
+ } else if (msg.type === "done") {
|
|
|
+ $("progress-bar").style.width = "100%";
|
|
|
+ $("status").textContent = "Done.";
|
|
|
+ $("progress-wrap").classList.add("hidden");
|
|
|
+ buildState(msg.result, lastFile);
|
|
|
+ renderAll();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------- state build ------------------------------- */
|
|
|
+function buildState(result, file) {
|
|
|
+ var nodeById = result.nodes;
|
|
|
+ var nodes = [];
|
|
|
+ for (var i = 0; i < nodeById.length; i++) if (nodeById[i]) nodes.push(nodeById[i]);
|
|
|
+
|
|
|
+ var groupName = Object.create(null);
|
|
|
+ for (var g = 0; g < result.budgetGroups.length; g++) groupName[result.budgetGroups[g].id] = result.budgetGroups[g].name;
|
|
|
+
|
|
|
+ // Collapse everything except the root unless the tree is small.
|
|
|
+ var collapsed = new Set();
|
|
|
+ for (var k = 0; k < nodes.length; k++) collapsed.add(nodes[k].id);
|
|
|
+ collapsed.delete(result.rootNodeId);
|
|
|
+ if (nodes.length <= 200) {
|
|
|
+ // small tree: expand fully
|
|
|
+ collapsed.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ state = {
|
|
|
+ r: result,
|
|
|
+ file: file,
|
|
|
+ nodeById: nodeById,
|
|
|
+ nodes: nodes,
|
|
|
+ groupName: groupName,
|
|
|
+ maxId: nodeById.length,
|
|
|
+ frameCount: result.frameCount,
|
|
|
+ rangeStart: 0,
|
|
|
+ rangeEnd: Math.max(0, result.frameCount - 1),
|
|
|
+ metricTime: result.anyTime,
|
|
|
+ sortKey: result.anyTime ? "total" : "name",
|
|
|
+ sortDir: result.anyTime ? -1 : 1,
|
|
|
+ collapsed: collapsed,
|
|
|
+ filter: "",
|
|
|
+ agg: result.aggregates,
|
|
|
+ self: new Float64Array(nodeById.length),
|
|
|
+ rootTotal: 0,
|
|
|
+ percentBase: 1
|
|
|
+ };
|
|
|
+ updateRangeInputs();
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------- aggregation ------------------------------- */
|
|
|
+function getAggregate(start, end) {
|
|
|
+ var s = state;
|
|
|
+ if (!s.r.samples || (start <= 0 && end >= s.frameCount - 1)) return s.r.aggregates;
|
|
|
+
|
|
|
+ var agg = [];
|
|
|
+ function get(id) {
|
|
|
+ var a = agg[id];
|
|
|
+ if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
|
|
|
+ return a;
|
|
|
+ }
|
|
|
+ var endClamped = Math.min(end, s.frameCount - 1);
|
|
|
+ for (var f = Math.max(0, start); f <= endClamped; f++) {
|
|
|
+ var fr = s.r.samples[f];
|
|
|
+ if (!fr) continue;
|
|
|
+ var ids = fr.ids, calls = fr.calls, us = fr.us;
|
|
|
+ for (var i = 0; i < ids.length; i++) {
|
|
|
+ var id = ids[i];
|
|
|
+ var a = get(id);
|
|
|
+ a.calls += calls[i];
|
|
|
+ a.us += us[i];
|
|
|
+ if (us[i] > a.maxUs) a.maxUs = us[i];
|
|
|
+ a.frames++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return agg;
|
|
|
+}
|
|
|
+
|
|
|
+function recompute() {
|
|
|
+ var s = state;
|
|
|
+ var agg = getAggregate(s.rangeStart, s.rangeEnd);
|
|
|
+ s.agg = agg;
|
|
|
+
|
|
|
+ var self = new Float64Array(s.maxId);
|
|
|
+ for (var id = 0; id < agg.length; id++) if (agg[id]) self[id] = agg[id].us;
|
|
|
+ for (var id2 = 0; id2 < agg.length; id2++) {
|
|
|
+ if (!agg[id2]) continue;
|
|
|
+ var n = s.nodeById[id2];
|
|
|
+ if (!n) continue;
|
|
|
+ var kids = n.children;
|
|
|
+ for (var c = 0; c < kids.length; c++) if (agg[kids[c]]) self[id2] -= agg[kids[c]].us;
|
|
|
+ }
|
|
|
+ s.self = self;
|
|
|
+
|
|
|
+ var rootAgg = agg[s.r.rootNodeId];
|
|
|
+ s.rootTotal = rootAgg ? rootAgg.us : 0;
|
|
|
+ if (s.metricTime) {
|
|
|
+ s.percentBase = s.rootTotal || 1;
|
|
|
+ } else {
|
|
|
+ s.percentBase = (rootAgg && rootAgg.calls) || 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------- rendering ------------------------------- */
|
|
|
+function renderAll() {
|
|
|
+ recompute();
|
|
|
+ renderSummary();
|
|
|
+ drawTimeline();
|
|
|
+ renderTree();
|
|
|
+ renderBudgets();
|
|
|
+ renderTop();
|
|
|
+ ["summary-section", "timeline-section", "tree-section", "budget-section", "top-section"]
|
|
|
+ .forEach(function (id) { $(id).classList.remove("hidden"); });
|
|
|
+
|
|
|
+ // Range scoping needs per-frame samples; disable it when they were dropped.
|
|
|
+ var rangeDisabled = !state.r.samples;
|
|
|
+ ["range-start", "range-end", "range-apply", "frame-prev", "frame-next"].forEach(function (id) {
|
|
|
+ $(id).disabled = rangeDisabled;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function renderSummary() {
|
|
|
+ var s = state, r = s.r;
|
|
|
+
|
|
|
+ var notices = $("notices");
|
|
|
+ notices.innerHTML = "";
|
|
|
+ if (!r.anyTime) {
|
|
|
+ var d = document.createElement("div");
|
|
|
+ d.className = "warn";
|
|
|
+ d.innerHTML = "<strong>No timing data in this recording.</strong> Every sampled " +
|
|
|
+ "microsecond value is zero, so time/self/percentage columns are not meaningful. " +
|
|
|
+ "This is common for captures made by the engine's snapshot path, which clears the " +
|
|
|
+ "current frame's counters before writing them. The call tree, call counts and " +
|
|
|
+ "structure below are still shown.";
|
|
|
+ notices.appendChild(d);
|
|
|
+ }
|
|
|
+ if (r.samplesOverflow) {
|
|
|
+ var d2 = document.createElement("div");
|
|
|
+ d2.className = "notice";
|
|
|
+ d2.textContent = "This recording is very large (" + fmtInt(r.totalSamples) +
|
|
|
+ " samples), so per-frame samples were not retained. Statistics cover the whole " +
|
|
|
+ "recording and frame-range selection is disabled.";
|
|
|
+ notices.appendChild(d2);
|
|
|
+ }
|
|
|
+
|
|
|
+ var rows = [
|
|
|
+ ["File", s.file ? s.file.name : "(unknown)"],
|
|
|
+ ["Size", s.file ? fmtBytes(s.file.size) : "-"],
|
|
|
+ ["Format version", r.version],
|
|
|
+ ["Frames", fmtInt(r.frameCount)],
|
|
|
+ ["Nodes", fmtInt(s.nodes.length)],
|
|
|
+ ["Budget groups", fmtInt(r.budgetGroups.length)],
|
|
|
+ ["Samples", fmtInt(r.totalSamples)],
|
|
|
+ ["Total time", fmtUs(Math.round(s.rootTotal))],
|
|
|
+ ["Parse time", Math.round(r.parseMs) + " ms"]
|
|
|
+ ];
|
|
|
+ $("summary").innerHTML = rows.map(function (row) {
|
|
|
+ return '<div><div class="k">' + esc(row[0]) + '</div><div class="v">' + esc(row[1]) + "</div></div>";
|
|
|
+ }).join("");
|
|
|
+}
|
|
|
+
|
|
|
+/* ---- timeline ---- */
|
|
|
+var drag = null;
|
|
|
+
|
|
|
+function useCallsForTimeline() {
|
|
|
+ return !state.r.anyTime;
|
|
|
+}
|
|
|
+function timelineData() {
|
|
|
+ return useCallsForTimeline() ? state.r.frameCalls : state.r.frameTotals;
|
|
|
+}
|
|
|
+function frameAt(clientX) {
|
|
|
+ var c = $("timeline");
|
|
|
+ var rect = c.getBoundingClientRect();
|
|
|
+ var x = clientX - rect.left;
|
|
|
+ var f = Math.floor((x / rect.width) * state.frameCount);
|
|
|
+ if (f < 0) f = 0;
|
|
|
+ if (f > state.frameCount - 1) f = state.frameCount - 1;
|
|
|
+ return f;
|
|
|
+}
|
|
|
+function drawTimeline() {
|
|
|
+ var c = $("timeline");
|
|
|
+ var wrap = c.parentElement;
|
|
|
+ var w = wrap.clientWidth || 600;
|
|
|
+ var h = 120;
|
|
|
+ var dpr = window.devicePixelRatio || 1;
|
|
|
+ c.width = Math.round(w * dpr);
|
|
|
+ c.height = Math.round(h * dpr);
|
|
|
+ c.style.width = w + "px";
|
|
|
+ c.style.height = h + "px";
|
|
|
+ var ctx = c.getContext("2d");
|
|
|
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
+ ctx.clearRect(0, 0, w, h);
|
|
|
+
|
|
|
+ var s = state;
|
|
|
+ var data = timelineData();
|
|
|
+ var max = 0;
|
|
|
+ for (var i = 0; i < s.frameCount; i++) if (data[i] > max) max = data[i];
|
|
|
+
|
|
|
+ var selA = s.rangeStart, selB = s.rangeEnd;
|
|
|
+ if (drag && drag.active) {
|
|
|
+ selA = Math.min(drag.start, drag.end);
|
|
|
+ selB = Math.max(drag.start, drag.end);
|
|
|
+ }
|
|
|
+
|
|
|
+ var padTop = 4, padBottom = 16;
|
|
|
+ var plotH = h - padTop - padBottom;
|
|
|
+ var bw = s.frameCount ? (w / s.frameCount) : w;
|
|
|
+
|
|
|
+ for (var f = 0; f < s.frameCount; f++) {
|
|
|
+ var v = data[f];
|
|
|
+ var bh = max ? (v / max) * plotH : 0;
|
|
|
+ var x = f * 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
|
|
|
+ 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(); }
|
|
|
+
|
|
|
+ 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.";
|
|
|
+ $("frame-label").textContent = "selected frames " + selA + "-" + selB;
|
|
|
+}
|
|
|
+function applyRange(a, b) {
|
|
|
+ var s = state;
|
|
|
+ a = Math.max(0, Math.min(a, s.frameCount - 1));
|
|
|
+ b = Math.max(0, Math.min(b, s.frameCount - 1));
|
|
|
+ if (a > b) { var t = a; a = b; b = t; }
|
|
|
+ s.rangeStart = a; s.rangeEnd = b;
|
|
|
+ updateRangeInputs();
|
|
|
+ renderAll();
|
|
|
+}
|
|
|
+function updateRangeInputs() {
|
|
|
+ $("range-start").value = state.rangeStart;
|
|
|
+ $("range-end").value = state.rangeEnd;
|
|
|
+}
|
|
|
+
|
|
|
+/* ---- tree ---- */
|
|
|
+function compareIds(aId, bId) {
|
|
|
+ var s = state;
|
|
|
+ if (s.sortKey === "name") {
|
|
|
+ return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name) * s.sortDir;
|
|
|
+ }
|
|
|
+ var a = s.agg[aId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
|
|
|
+ var b = s.agg[bId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
|
|
|
+ var av, bv;
|
|
|
+ switch (s.sortKey) {
|
|
|
+ case "calls": av = a.calls; bv = b.calls; break;
|
|
|
+ case "self": av = s.self[aId] || 0; bv = s.self[bId] || 0; break;
|
|
|
+ case "avg": av = a.frames ? a.us / a.frames : 0; bv = b.frames ? b.us / b.frames : 0; break;
|
|
|
+ case "max": av = a.maxUs; bv = b.maxUs; break;
|
|
|
+ default: av = a.us; bv = b.us;
|
|
|
+ }
|
|
|
+ if (av === bv) return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name);
|
|
|
+ return (av < bv ? -1 : 1) * s.sortDir;
|
|
|
+}
|
|
|
+function computeMatchSet(text) {
|
|
|
+ if (!text) return null;
|
|
|
+ var s = state;
|
|
|
+ var t = text.toLowerCase();
|
|
|
+ var set = new Set();
|
|
|
+ for (var i = 0; i < s.nodes.length; i++) {
|
|
|
+ var n = s.nodes[i];
|
|
|
+ if (n.name.toLowerCase().indexOf(t) !== -1) {
|
|
|
+ set.add(n.id);
|
|
|
+ var p = n.parentId;
|
|
|
+ while (p !== null && p !== undefined) { set.add(p); p = s.nodeById[p].parentId; }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return set;
|
|
|
+}
|
|
|
+function renderTree() {
|
|
|
+ var s = state;
|
|
|
+ var matchSet = computeMatchSet(s.filter);
|
|
|
+ var orderCache = Object.create(null);
|
|
|
+ function order(id) {
|
|
|
+ var c = orderCache[id];
|
|
|
+ if (c) return c;
|
|
|
+ c = s.nodeById[id].children.slice();
|
|
|
+ c.sort(compareIds);
|
|
|
+ orderCache[id] = c;
|
|
|
+ return c;
|
|
|
+ }
|
|
|
+
|
|
|
+ var rows = [];
|
|
|
+ function visit(id, depth) {
|
|
|
+ var n = s.nodeById[id];
|
|
|
+ if (!n) return;
|
|
|
+ if (matchSet && !matchSet.has(id)) return;
|
|
|
+ var a = s.agg[id] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
|
|
|
+ var totalUs = a.us;
|
|
|
+ var selfUs = s.self[id] || 0;
|
|
|
+ var pct = s.percentBase ? ((s.metricTime ? totalUs : a.calls) / s.percentBase) * 100 : 0;
|
|
|
+ rows.push({ id: id, depth: depth, n: n, a: a, totalUs: totalUs, selfUs: selfUs, pct: pct });
|
|
|
+ var collapsed = !matchSet && s.collapsed.has(id);
|
|
|
+ if (!collapsed) { order(id).forEach(function (c) { visit(c, depth + 1); }); }
|
|
|
+ }
|
|
|
+ visit(s.r.rootNodeId, 0);
|
|
|
+
|
|
|
+ // Header
|
|
|
+ var cols = [
|
|
|
+ { key: "name", label: "Scope", num: false },
|
|
|
+ { key: "calls", label: "Calls", num: true },
|
|
|
+ { key: "total", label: "Total", num: true },
|
|
|
+ { key: "self", label: "Self", num: true },
|
|
|
+ { key: "avg", label: "Avg", num: true },
|
|
|
+ { key: "max", label: "Max", num: true },
|
|
|
+ { key: "_pct", label: "%", num: true }
|
|
|
+ ];
|
|
|
+ $("tree-head").innerHTML = cols.map(function (c) {
|
|
|
+ var sortable = c.key !== "_pct";
|
|
|
+ var marker = "";
|
|
|
+ if (sortable && s.sortKey === c.key) marker = s.sortDir < 0 ? " \u25bc" : " \u25b2";
|
|
|
+ return '<th class="' + (sortable ? "" : "nosort") + (c.num ? " num" : "") +
|
|
|
+ '" data-key="' + (sortable ? c.key : "") + '">' + esc(c.label) + marker + "</th>";
|
|
|
+ }).join("");
|
|
|
+ Array.prototype.forEach.call($("tree-head").children, function (th) {
|
|
|
+ var key = th.getAttribute("data-key");
|
|
|
+ if (!key) return;
|
|
|
+ th.onclick = function () {
|
|
|
+ if (state.sortKey === key) state.sortDir = -state.sortDir;
|
|
|
+ else { state.sortKey = key; state.sortDir = key === "name" ? 1 : -1; }
|
|
|
+ renderTree();
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ var maxMetric = 0;
|
|
|
+ rows.forEach(function (r) { maxMetric = Math.max(maxMetric, s.metricTime ? r.totalUs : r.a.calls); });
|
|
|
+
|
|
|
+ var html = rows.map(function (r) {
|
|
|
+ var hasKids = r.n.children.length > 0;
|
|
|
+ var collapsed = !matchSet && s.collapsed.has(r.id);
|
|
|
+ var toggle = hasKids ? (collapsed ? "\u25b6" : "\u25bc") : "\u00a0";
|
|
|
+ var indent = "padding-left:" + (4 + r.depth * 14) + "px";
|
|
|
+ var metric = s.metricTime ? r.totalUs : r.a.calls;
|
|
|
+ var barW = maxMetric ? Math.round(60 * metric / maxMetric) : 0;
|
|
|
+ var avg = r.a.frames ? r.a.us / r.a.frames : 0;
|
|
|
+ var groupName = s.groupName[r.n.budgetGroupId] || ("group " + r.n.budgetGroupId);
|
|
|
+ return '<tr data-id="' + r.id + '">' +
|
|
|
+ '<td class="tree-name" style="' + indent + '"><span class="toggle">' + toggle + "</span>" +
|
|
|
+ esc(r.n.name) + ' <span class="muted">[' + esc(groupName) + "]</span>" +
|
|
|
+ '<div class="bar" style="width:' + barW + 'px"></div></td>' +
|
|
|
+ '<td class="num">' + fmtInt(r.a.calls) + "</td>" +
|
|
|
+ '<td class="num">' + (s.metricTime ? fmtUs(r.totalUs) : "-") + "</td>" +
|
|
|
+ '<td class="num">' + (s.metricTime ? fmtUs(r.selfUs) : "-") + "</td>" +
|
|
|
+ '<td class="num">' + (s.metricTime && r.a.frames ? fmtUs(avg) : "-") + "</td>" +
|
|
|
+ '<td class="num">' + (s.metricTime ? fmtUs(r.a.maxUs) : "-") + "</td>" +
|
|
|
+ '<td class="num">' + (s.metricTime ? r.pct.toFixed(2) + "%" : "-") + "</td>" +
|
|
|
+ "</tr>";
|
|
|
+ }).join("");
|
|
|
+ $("tree-body").innerHTML = html;
|
|
|
+ $("tree-count").textContent = rows.length + " rows shown";
|
|
|
+
|
|
|
+ Array.prototype.forEach.call($("tree-body").querySelectorAll("tr"), function (tr) {
|
|
|
+ tr.onclick = function (ev) {
|
|
|
+ var id = Number(tr.getAttribute("data-id"));
|
|
|
+ var n = s.nodeById[id];
|
|
|
+ if (!n || !n.children.length) return;
|
|
|
+ if (s.collapsed.has(id)) s.collapsed.delete(id); else s.collapsed.add(id);
|
|
|
+ renderTree();
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/* ---- budget groups ---- */
|
|
|
+function renderBudgets() {
|
|
|
+ var s = state;
|
|
|
+ var perGroup = Object.create(null);
|
|
|
+ for (var id = 0; id < s.agg.length; id++) {
|
|
|
+ var a = s.agg[id];
|
|
|
+ if (!a) continue;
|
|
|
+ var n = s.nodeById[id];
|
|
|
+ if (!n) continue;
|
|
|
+ var gid = n.budgetGroupId;
|
|
|
+ var e = perGroup[gid];
|
|
|
+ if (!e) e = perGroup[gid] = { us: 0, calls: 0 };
|
|
|
+ e.us += a.us; e.calls += a.calls;
|
|
|
+ }
|
|
|
+ var rows = [];
|
|
|
+ for (var gi = 0; gi < s.r.budgetGroups.length; gi++) {
|
|
|
+ var g = s.r.budgetGroups[gi];
|
|
|
+ var e2 = perGroup[g.id];
|
|
|
+ if (!e2 && !g.isDefault) continue;
|
|
|
+ rows.push({ id: g.id, name: g.name, us: e2 ? e2.us : 0, calls: e2 ? e2.calls : 0 });
|
|
|
+ }
|
|
|
+ var metric = s.metricTime ? "us" : "calls";
|
|
|
+ rows.sort(function (a, b) { return b[metric] - a[metric]; });
|
|
|
+
|
|
|
+ $("budget-body").innerHTML = rows.map(function (r) {
|
|
|
+ var pct = s.percentBase ? (r[metric] / s.percentBase) * 100 : 0;
|
|
|
+ return "<tr><td>" + esc(r.name) + "</td><td class='num'>" + fmtInt(r.calls) + "</td>" +
|
|
|
+ "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
|
|
|
+ "<td class='num'>" + (s.metricTime ? pct.toFixed(2) + "%" : "-") + "</td></tr>";
|
|
|
+ }).join("");
|
|
|
+}
|
|
|
+
|
|
|
+/* ---- top nodes ---- */
|
|
|
+function renderTop() {
|
|
|
+ var s = state;
|
|
|
+ var rows = [];
|
|
|
+ for (var id = 0; id < s.agg.length; id++) {
|
|
|
+ var a = s.agg[id];
|
|
|
+ if (!a) continue;
|
|
|
+ var n = s.nodeById[id];
|
|
|
+ if (!n) continue;
|
|
|
+ rows.push({ id: id, name: n.name, us: a.us, self: s.self[id] || 0, calls: a.calls });
|
|
|
+ }
|
|
|
+ if (s.metricTime) rows.sort(function (a, b) { return b.us - a.us; });
|
|
|
+ else rows.sort(function (a, b) { return b.calls - a.calls; });
|
|
|
+ rows = rows.slice(0, 100);
|
|
|
+
|
|
|
+ $("top-body").innerHTML = rows.map(function (r) {
|
|
|
+ return "<tr><td>" + esc(r.name) + "</td>" +
|
|
|
+ "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
|
|
|
+ "<td class='num'>" + (s.metricTime ? fmtUs(r.self) : "-") + "</td>" +
|
|
|
+ "<td class='num'>" + fmtInt(r.calls) + "</td></tr>";
|
|
|
+ }).join("");
|
|
|
+}
|
|
|
+
|
|
|
+/* ------------------------------- events ------------------------------- */
|
|
|
+$("file").addEventListener("change", function (e) {
|
|
|
+ var f = e.target.files && e.target.files[0];
|
|
|
+ if (f) startParse(f);
|
|
|
+});
|
|
|
+document.addEventListener("dragover", function (e) { e.preventDefault(); });
|
|
|
+document.addEventListener("drop", function (e) {
|
|
|
+ e.preventDefault();
|
|
|
+ var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
|
|
+ if (f) startParse(f);
|
|
|
+});
|
|
|
+
|
|
|
+$("range-all").onclick = function () { applyRange(0, state.frameCount - 1); };
|
|
|
+$("range-apply").onclick = function () {
|
|
|
+ applyRange(Number($("range-start").value), Number($("range-end").value));
|
|
|
+};
|
|
|
+$("frame-prev").onclick = function () {
|
|
|
+ var f = Math.max(0, state.rangeStart - 1); applyRange(f, f);
|
|
|
+};
|
|
|
+$("frame-next").onclick = function () {
|
|
|
+ var f = Math.min(state.frameCount - 1, state.rangeEnd + 1); applyRange(f, f);
|
|
|
+};
|
|
|
+$("expand-all").onclick = function () { state.collapsed.clear(); renderTree(); };
|
|
|
+$("collapse-all").onclick = function () {
|
|
|
+ state.collapsed.clear();
|
|
|
+ for (var i = 0; i < state.nodes.length; i++) state.collapsed.add(state.nodes[i].id);
|
|
|
+ state.collapsed.delete(state.r.rootNodeId);
|
|
|
+ renderTree();
|
|
|
+};
|
|
|
+var filterTimer = null;
|
|
|
+$("filter").addEventListener("input", function (e) {
|
|
|
+ clearTimeout(filterTimer);
|
|
|
+ filterTimer = setTimeout(function () { state.filter = e.target.value.trim(); renderTree(); }, 120);
|
|
|
+});
|
|
|
+
|
|
|
+/* timeline drag selection */
|
|
|
+(function () {
|
|
|
+ var c = $("timeline");
|
|
|
+ c.addEventListener("mousedown", function (e) {
|
|
|
+ if (!state || !state.r.samples) return;
|
|
|
+ e.preventDefault();
|
|
|
+ drag = { active: true, start: frameAt(e.clientX), end: frameAt(e.clientX) };
|
|
|
+ drawTimeline();
|
|
|
+ });
|
|
|
+ window.addEventListener("mousemove", function (e) {
|
|
|
+ if (!drag || !drag.active) return;
|
|
|
+ drag.end = frameAt(e.clientX);
|
|
|
+ drawTimeline();
|
|
|
+ });
|
|
|
+ window.addEventListener("mouseup", function (e) {
|
|
|
+ if (!drag || !drag.active) return;
|
|
|
+ drag.end = frameAt(e.clientX);
|
|
|
+ var a = drag.start, b = drag.end;
|
|
|
+ drag = null;
|
|
|
+ applyRange(a, b);
|
|
|
+ });
|
|
|
+ window.addEventListener("resize", function () { if (state) drawTimeline(); });
|
|
|
+})();
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|