Timeline.js 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684
  1. import { Tab } from '../ui/Tab.js';
  2. import { Graph } from '../ui/Graph.js';
  3. import { getItem, setItem } from '../Inspector.js';
  4. import { info } from '../ui/utils.js';
  5. import {
  6. ByteType,
  7. FloatType,
  8. HalfFloatType,
  9. IntType,
  10. ShortType,
  11. UnsignedByteType,
  12. UnsignedInt101111Type,
  13. UnsignedInt248Type,
  14. UnsignedInt5999Type,
  15. UnsignedIntType,
  16. UnsignedShort4444Type,
  17. UnsignedShort5551Type,
  18. UnsignedShortType,
  19. AlphaFormat,
  20. RGBFormat,
  21. RGBAFormat,
  22. DepthFormat,
  23. DepthStencilFormat,
  24. RedFormat,
  25. RedIntegerFormat,
  26. RGFormat,
  27. RGIntegerFormat,
  28. RGBIntegerFormat,
  29. RGBAIntegerFormat
  30. } from 'three';
  31. const LIMIT = 500;
  32. const TRIANGLES_GRAPH_LIMIT = 60;
  33. class Timeline extends Tab {
  34. constructor( options = {} ) {
  35. super( 'Timeline', options );
  36. this.content.style.overflow = 'hidden';
  37. this.isRecording = false;
  38. this.frames = []; // Array of { id: number, calls: [] }
  39. this.baseTriangles = 0;
  40. this.currentFrame = null;
  41. this.isHierarchicalView = true;
  42. this.callBlocks = new WeakMap();
  43. this.fallbackBlocks = [];
  44. this.originalBackend = null;
  45. this.originalMethods = new Map();
  46. this.renderer = null;
  47. this.graph = new Graph( LIMIT ); // Accommodate standard graph points
  48. // Make lines in timeline graph
  49. this.graph.addLine( 'fps', 'var( --color-fps )' );
  50. this.graph.addLine( 'calls', 'var( --color-call )' );
  51. this.graph.addLine( 'triangles', 'var( --color-red )' );
  52. this.buildHeader();
  53. this.buildUI();
  54. // Bind window resize to update graph bounds
  55. window.addEventListener( 'resize', () => {
  56. if ( ! this.isRecording && this.frames.length > 0 ) {
  57. this.renderSlider();
  58. }
  59. } );
  60. }
  61. init( inspector ) {
  62. super.init( inspector );
  63. this.profiler.addEventListener( 'resize', () => {
  64. if ( ! this.isRecording && this.frames.length > 0 ) {
  65. this.renderSlider();
  66. }
  67. } );
  68. }
  69. buildHeader() {
  70. const header = document.createElement( 'div' );
  71. header.className = 'toolbar';
  72. this.recordButton = document.createElement( 'button' );
  73. this.recordButton.className = 'console-copy-button'; // Reusing style
  74. this.recordButton.title = 'Record';
  75. this.recordButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4" fill="currentColor"></circle></svg>';
  76. this.recordButton.style.padding = '0 10px';
  77. this.recordButton.style.lineHeight = '24px'; // Match other buttons height
  78. this.recordButton.style.display = 'flex';
  79. this.recordButton.style.alignItems = 'center';
  80. this.recordButton.addEventListener( 'click', () => this.toggleRecording() );
  81. const clearButton = document.createElement( 'button' );
  82. clearButton.className = 'console-copy-button';
  83. clearButton.title = 'Clear';
  84. clearButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>';
  85. clearButton.style.padding = '0 10px';
  86. clearButton.style.lineHeight = '24px';
  87. clearButton.style.display = 'flex';
  88. clearButton.style.alignItems = 'center';
  89. clearButton.addEventListener( 'click', () => this.clear() );
  90. this.viewModeSelect = document.createElement( 'select' );
  91. this.viewModeSelect.className = 'select';
  92. this.viewModeSelect.style.width = '120px';
  93. this.viewModeSelect.style.marginRight = '10px';
  94. const hierarchyOption = document.createElement( 'option' );
  95. hierarchyOption.value = 'hierarchy';
  96. hierarchyOption.textContent = 'Hierarchy';
  97. this.viewModeSelect.appendChild( hierarchyOption );
  98. const countsOption = document.createElement( 'option' );
  99. countsOption.value = 'counts';
  100. countsOption.textContent = 'Count';
  101. this.viewModeSelect.appendChild( countsOption );
  102. this.viewModeSelect.value = this.isHierarchicalView ? 'hierarchy' : 'counts';
  103. this.viewModeSelect.addEventListener( 'change', () => {
  104. this.isHierarchicalView = this.viewModeSelect.value === 'hierarchy';
  105. if ( this.selectedFrameIndex !== undefined && this.selectedFrameIndex !== - 1 ) {
  106. this.selectFrame( this.selectedFrameIndex );
  107. }
  108. } );
  109. this.recordRefreshButton = document.createElement( 'button' );
  110. this.recordRefreshButton.className = 'console-copy-button'; // Reusing style
  111. this.recordRefreshButton.title = 'Refresh & Record';
  112. this.recordRefreshButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path><circle cx="12" cy="12" r="3" fill="currentColor"></circle></svg>';
  113. this.recordRefreshButton.style.padding = '0 10px';
  114. this.recordRefreshButton.style.lineHeight = '24px';
  115. this.recordRefreshButton.style.display = 'flex';
  116. this.recordRefreshButton.style.alignItems = 'center';
  117. this.recordRefreshButton.addEventListener( 'click', () => {
  118. const timelineSettings = getItem( 'timeline' );
  119. timelineSettings.recording = true;
  120. setItem( 'timeline', timelineSettings );
  121. window.location.reload();
  122. } );
  123. this.exportButton = document.createElement( 'button' );
  124. this.exportButton.className = 'console-copy-button';
  125. this.exportButton.title = 'Export';
  126. this.exportButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
  127. this.exportButton.style.padding = '0 10px';
  128. this.exportButton.style.lineHeight = '24px';
  129. this.exportButton.style.display = 'flex';
  130. this.exportButton.style.alignItems = 'center';
  131. this.exportButton.addEventListener( 'click', () => this.exportData() );
  132. const buttonsGroup = document.createElement( 'div' );
  133. buttonsGroup.className = 'console-buttons-group';
  134. buttonsGroup.appendChild( this.recordButton );
  135. buttonsGroup.appendChild( this.recordRefreshButton );
  136. buttonsGroup.appendChild( this.exportButton );
  137. buttonsGroup.appendChild( clearButton );
  138. const titleElement = document.createElement( 'div' );
  139. titleElement.style.display = 'flex';
  140. titleElement.style.alignItems = 'center';
  141. titleElement.style.color = 'var(--text-primary)';
  142. titleElement.style.alignSelf = 'center';
  143. this.frameInfo = document.createElement( 'span' );
  144. this.frameInfo.style.display = 'inline-flex';
  145. this.frameInfo.style.alignItems = 'center';
  146. this.frameInfo.style.marginLeft = '5px';
  147. this.frameInfo.style.fontFamily = 'monospace';
  148. this.frameInfo.style.color = 'var(--text-secondary)';
  149. this.frameInfo.style.fontSize = '12px';
  150. titleElement.appendChild( this.viewModeSelect );
  151. titleElement.appendChild( this.frameInfo );
  152. header.appendChild( titleElement );
  153. header.appendChild( buttonsGroup );
  154. this.content.appendChild( header );
  155. }
  156. buildUI() {
  157. const container = document.createElement( 'div' );
  158. container.style.display = 'flex';
  159. container.style.flexDirection = 'column';
  160. container.style.flex = '1';
  161. container.style.minHeight = '0';
  162. container.style.marginTop = '10px';
  163. container.style.width = '100%';
  164. // Top Player/Graph Slider using Graph.js SVG
  165. const graphContainer = document.createElement( 'div' );
  166. graphContainer.style.height = '60px';
  167. graphContainer.style.minHeight = '60px';
  168. graphContainer.style.borderBottom = '1px solid var(--border-color)';
  169. graphContainer.style.backgroundColor = 'var(--background-color)';
  170. this.graphSlider = document.createElement( 'div' );
  171. this.graphSlider.style.height = '100%';
  172. this.graphSlider.style.margin = '0 10px';
  173. this.graphSlider.style.position = 'relative';
  174. this.graphSlider.style.cursor = 'crosshair';
  175. graphContainer.appendChild( this.graphSlider );
  176. // Setup SVG from Graph
  177. this.graph.domElement.style.width = '0';
  178. this.graph.domElement.style.minWidth = '100%';
  179. this.graph.domElement.style.height = '100%';
  180. this.graphSlider.appendChild( this.graph.domElement );
  181. // Hover indicator
  182. this.hoverIndicator = document.createElement( 'div' );
  183. this.hoverIndicator.style.position = 'absolute';
  184. this.hoverIndicator.style.top = '0';
  185. this.hoverIndicator.style.bottom = '0';
  186. this.hoverIndicator.style.width = '1px';
  187. this.hoverIndicator.style.backgroundColor = 'rgba(255, 255, 255, 0.3)';
  188. this.hoverIndicator.style.pointerEvents = 'none';
  189. this.hoverIndicator.style.display = 'none';
  190. this.hoverIndicator.style.zIndex = '9';
  191. this.hoverIndicator.style.transform = 'translateX(-50%)';
  192. this.graphSlider.appendChild( this.hoverIndicator );
  193. // Playhead indicator (vertical line)
  194. this.playhead = document.createElement( 'div' );
  195. this.playhead.style.position = 'absolute';
  196. this.playhead.style.top = '0';
  197. this.playhead.style.bottom = '0';
  198. this.playhead.style.width = '2px';
  199. this.playhead.style.backgroundColor = 'var(--color-red)';
  200. this.playhead.style.boxShadow = '0 0 4px rgba(255,0,0,0.5)';
  201. this.playhead.style.pointerEvents = 'none';
  202. this.playhead.style.display = 'none';
  203. this.playhead.style.zIndex = '10';
  204. this.playhead.style.transform = 'translateX(-50%)';
  205. this.graphSlider.appendChild( this.playhead );
  206. // Playhead handle (triangle/pointer)
  207. const playheadHandle = document.createElement( 'div' );
  208. playheadHandle.style.position = 'absolute';
  209. playheadHandle.style.top = '0';
  210. playheadHandle.style.left = '50%';
  211. playheadHandle.style.transform = 'translate(-50%, 0)';
  212. playheadHandle.style.width = '0';
  213. playheadHandle.style.height = '0';
  214. playheadHandle.style.borderLeft = '6px solid transparent';
  215. playheadHandle.style.borderRight = '6px solid transparent';
  216. playheadHandle.style.borderTop = '8px solid var(--color-red)';
  217. this.playhead.appendChild( playheadHandle );
  218. // Make it focusable to accept keyboard events
  219. this.graphSlider.tabIndex = 0;
  220. this.graphSlider.style.outline = 'none';
  221. // Mouse interactivity on the graph
  222. let isDragging = false;
  223. const updatePlayheadFromEvent = ( e ) => {
  224. if ( this.isRecording || this.frames.length === 0 ) return;
  225. const rect = this.graphSlider.getBoundingClientRect();
  226. let x = e.clientX - rect.left;
  227. // Clamp
  228. x = Math.max( 0, Math.min( x, rect.width ) );
  229. this.fixedScreenX = x;
  230. // The graph stretches its points across the width
  231. // Find closest frame index based on exact point coordinates
  232. const pointCount = this.graph.lines[ 'calls' ].points.length;
  233. if ( pointCount === 0 ) return;
  234. const pointStep = rect.width / ( this.graph.maxPoints - 1 );
  235. const offset = rect.width - ( ( pointCount - 1 ) * pointStep );
  236. let localFrameIndex = Math.round( ( x - offset ) / pointStep );
  237. localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) );
  238. if ( localFrameIndex >= pointCount - 2 ) {
  239. this.isTrackingLatest = true;
  240. } else {
  241. this.isTrackingLatest = false;
  242. }
  243. let frameIndex = localFrameIndex;
  244. if ( this.frames.length > pointCount ) {
  245. frameIndex += this.frames.length - pointCount;
  246. }
  247. this.playhead.style.display = 'block';
  248. this.selectFrame( frameIndex );
  249. };
  250. this.graphSlider.addEventListener( 'mousedown', ( e ) => {
  251. if ( this.isRecording ) return;
  252. isDragging = true;
  253. this.isManualScrubbing = true;
  254. this.graphSlider.focus();
  255. updatePlayheadFromEvent( e );
  256. } );
  257. this.graphSlider.addEventListener( 'mouseenter', () => {
  258. if ( this.frames.length > 0 && ! this.isRecording ) {
  259. this.hoverIndicator.style.display = 'block';
  260. }
  261. } );
  262. this.graphSlider.addEventListener( 'mouseleave', () => {
  263. this.hoverIndicator.style.display = 'none';
  264. } );
  265. this.graphSlider.addEventListener( 'mousemove', ( e ) => {
  266. if ( this.frames.length === 0 || this.isRecording ) return;
  267. const rect = this.graphSlider.getBoundingClientRect();
  268. let x = e.clientX - rect.left;
  269. x = Math.max( 0, Math.min( x, rect.width ) );
  270. const pointCount = this.graph.lines[ 'calls' ].points.length;
  271. if ( pointCount > 0 ) {
  272. const pointStep = rect.width / ( this.graph.maxPoints - 1 );
  273. const offset = rect.width - ( ( pointCount - 1 ) * pointStep );
  274. let localFrameIndex = Math.round( ( x - offset ) / pointStep );
  275. localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) );
  276. let snappedX = offset + localFrameIndex * pointStep;
  277. snappedX = Math.max( 1, Math.min( snappedX, rect.width - 1 ) );
  278. this.hoverIndicator.style.left = snappedX + 'px';
  279. } else {
  280. const clampedX = Math.max( 1, Math.min( x, rect.width - 1 ) );
  281. this.hoverIndicator.style.left = clampedX + 'px';
  282. }
  283. } );
  284. this.graphSlider.addEventListener( 'keydown', ( e ) => {
  285. if ( this.frames.length === 0 || this.isRecording ) return;
  286. let newIndex = this.selectedFrameIndex;
  287. if ( e.key === 'ArrowLeft' ) {
  288. newIndex = Math.max( 0, this.selectedFrameIndex - 1 );
  289. e.preventDefault();
  290. } else if ( e.key === 'ArrowRight' ) {
  291. newIndex = Math.min( this.frames.length - 1, this.selectedFrameIndex + 1 );
  292. e.preventDefault();
  293. }
  294. if ( newIndex !== this.selectedFrameIndex ) {
  295. this.selectFrame( newIndex );
  296. // Update playhead tracking state
  297. const pointCount = this.graph.lines[ 'calls' ].points.length;
  298. if ( pointCount > 0 ) {
  299. let localIndex = newIndex;
  300. if ( this.frames.length > pointCount ) {
  301. localIndex = newIndex - ( this.frames.length - pointCount );
  302. }
  303. if ( localIndex >= pointCount - 2 ) {
  304. this.isTrackingLatest = true;
  305. } else {
  306. this.isTrackingLatest = false;
  307. }
  308. const rect = this.graphSlider.getBoundingClientRect();
  309. const pointStep = rect.width / ( this.graph.maxPoints - 1 );
  310. const offset = rect.width - ( ( pointCount - 1 ) * pointStep );
  311. this.fixedScreenX = offset + localIndex * pointStep;
  312. }
  313. }
  314. } );
  315. window.addEventListener( 'mousemove', ( e ) => {
  316. if ( isDragging ) {
  317. updatePlayheadFromEvent( e );
  318. // Also move hover indicator to match playback
  319. const rect = this.graphSlider.getBoundingClientRect();
  320. let x = e.clientX - rect.left;
  321. x = Math.max( 0, Math.min( x, rect.width ) );
  322. const pointCount = this.graph.lines[ 'calls' ].points.length;
  323. if ( pointCount > 0 ) {
  324. const pointStep = rect.width / ( this.graph.maxPoints - 1 );
  325. const offset = rect.width - ( ( pointCount - 1 ) * pointStep );
  326. let localFrameIndex = Math.round( ( x - offset ) / pointStep );
  327. localFrameIndex = Math.max( 0, Math.min( localFrameIndex, pointCount - 1 ) );
  328. let snappedX = offset + localFrameIndex * pointStep;
  329. snappedX = Math.max( 1, Math.min( snappedX, rect.width - 1 ) );
  330. this.hoverIndicator.style.left = snappedX + 'px';
  331. } else {
  332. const clampedX = Math.max( 1, Math.min( x, rect.width - 1 ) );
  333. this.hoverIndicator.style.left = clampedX + 'px';
  334. }
  335. }
  336. } );
  337. window.addEventListener( 'mouseup', () => {
  338. isDragging = false;
  339. this.isManualScrubbing = false;
  340. } );
  341. container.appendChild( graphContainer );
  342. // Bottom Main Area (Timeline Sequence)
  343. const mainArea = document.createElement( 'div' );
  344. mainArea.style.flex = '1';
  345. mainArea.style.display = 'flex';
  346. mainArea.style.flexDirection = 'column';
  347. mainArea.style.overflow = 'hidden';
  348. // Timeline Track
  349. this.timelineTrack = document.createElement( 'div' );
  350. this.timelineTrack.style.flex = '1';
  351. this.timelineTrack.style.overflowY = 'auto';
  352. this.timelineTrack.style.margin = '10px';
  353. this.timelineTrack.style.marginTop = '8px';
  354. this.timelineTrack.style.backgroundColor = 'var(--background-color)';
  355. mainArea.appendChild( this.timelineTrack );
  356. container.appendChild( mainArea );
  357. this.content.appendChild( container );
  358. }
  359. setRenderer( renderer ) {
  360. this.renderer = renderer;
  361. const timelineSettings = getItem( 'timeline' );
  362. if ( timelineSettings.recording ) {
  363. timelineSettings.recording = false;
  364. setItem( 'timeline', timelineSettings );
  365. this.toggleRecording();
  366. }
  367. }
  368. toggleRecording() {
  369. if ( ! this.renderer ) {
  370. console.warn( 'Timeline: No renderer defined.' );
  371. return;
  372. }
  373. this.isRecording = ! this.isRecording;
  374. if ( this.isRecording ) {
  375. this.recordButton.title = 'Stop';
  376. this.recordButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect></svg>';
  377. this.recordButton.style.color = 'var(--color-red)';
  378. this.startRecording();
  379. } else {
  380. this.recordButton.title = 'Record';
  381. this.recordButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="4" fill="currentColor"></circle></svg>';
  382. this.recordButton.style.color = '';
  383. this.stopRecording();
  384. this.renderSlider();
  385. }
  386. }
  387. startRecording() {
  388. this.frames = [];
  389. this.currentFrame = null;
  390. this.selectedFrameIndex = - 1;
  391. this.fixedScreenX = 0;
  392. this.isTrackingLatest = true;
  393. this.isManualScrubbing = false;
  394. this.clear();
  395. this.frameInfo.textContent = 'Recording...';
  396. const backend = this.renderer.backend;
  397. const methods = Object.getOwnPropertyNames( Object.getPrototypeOf( backend ) ).filter( prop => prop !== 'constructor' );
  398. for ( const prop of methods ) {
  399. const descriptor = Object.getOwnPropertyDescriptor( Object.getPrototypeOf( backend ), prop );
  400. if ( descriptor && ( descriptor.get || descriptor.set ) ) continue;
  401. const originalFunc = backend[ prop ];
  402. if ( typeof originalFunc === 'function' && typeof prop === 'string' ) {
  403. this.originalMethods.set( prop, originalFunc );
  404. backend[ prop ] = ( ...args ) => {
  405. if ( prop.toLowerCase().includes( 'timestamp' ) || prop.startsWith( 'get' ) || prop.startsWith( 'set' ) || prop.startsWith( 'has' ) || prop.startsWith( '_' ) || prop.startsWith( 'needs' ) ) {
  406. return originalFunc.apply( backend, args );
  407. }
  408. // Check for frame change
  409. const frameNumber = this.renderer.info.frame;
  410. if ( ! this.currentFrame || this.currentFrame.id !== frameNumber ) {
  411. if ( this.currentFrame ) {
  412. this.currentFrame.fps = this.renderer.inspector ? this.renderer.inspector.fps : 0;
  413. if ( ! isFinite( this.currentFrame.fps ) ) {
  414. this.currentFrame.fps = 0;
  415. }
  416. const t = this.currentFrame.triangles || 0;
  417. if ( t > this.baseTriangles ) {
  418. const oldBase = this.baseTriangles;
  419. this.baseTriangles = t;
  420. if ( oldBase > 0 ) {
  421. const ratio = oldBase / this.baseTriangles;
  422. const points = this.graph.lines[ 'triangles' ].points;
  423. for ( let i = 0; i < points.length; i ++ ) {
  424. points[ i ] *= ratio;
  425. }
  426. }
  427. }
  428. const normalizedTriangles = this.baseTriangles > 0 ? ( t / this.baseTriangles ) * TRIANGLES_GRAPH_LIMIT : 0;
  429. this.graph.addPoint( 'calls', this.currentFrame.calls.length );
  430. this.graph.addPoint( 'fps', this.currentFrame.fps );
  431. this.graph.addPoint( 'triangles', normalizedTriangles );
  432. this.graph.update();
  433. }
  434. this.currentFrame = { id: frameNumber, calls: [], fps: 0, triangles: 0 };
  435. this.frames.push( this.currentFrame );
  436. if ( this.frames.length > LIMIT ) {
  437. this.frames.shift();
  438. }
  439. }
  440. const call = { method: prop, target: args[ 0 ] };
  441. const details = this.getCallDetail( prop, args );
  442. if ( details ) {
  443. call.details = details;
  444. if ( details.triangles !== undefined ) {
  445. this.currentFrame.triangles += details.triangles;
  446. }
  447. }
  448. this.currentFrame.calls.push( call );
  449. return originalFunc.apply( backend, args );
  450. };
  451. }
  452. }
  453. }
  454. stopRecording() {
  455. if ( this.originalMethods.size > 0 ) {
  456. const backend = this.renderer.backend;
  457. for ( const [ prop, originalFunc ] of this.originalMethods.entries() ) {
  458. backend[ prop ] = originalFunc;
  459. }
  460. this.originalMethods.clear();
  461. if ( this.currentFrame ) {
  462. this.currentFrame.fps = this.renderer.inspector ? this.renderer.inspector.fps : 0;
  463. }
  464. }
  465. }
  466. clear() {
  467. this.frames = [];
  468. this.timelineTrack.innerHTML = '';
  469. this.playhead.style.display = 'none';
  470. this.frameInfo.textContent = '';
  471. this.baseTriangles = 0;
  472. this.graph.lines[ 'calls' ].points = [];
  473. this.graph.lines[ 'fps' ].points = [];
  474. this.graph.lines[ 'triangles' ].points = [];
  475. this.graph.resetLimit();
  476. this.graph.update();
  477. }
  478. exportData() {
  479. if ( this.frames.length === 0 ) return;
  480. const data = JSON.stringify( this.frames, null, '\t' );
  481. const blob = new Blob( [ data ], { type: 'application/json' } );
  482. const url = URL.createObjectURL( blob );
  483. const a = document.createElement( 'a' );
  484. a.href = url;
  485. a.download = 'threejs-timeline.json';
  486. a.click();
  487. URL.revokeObjectURL( url );
  488. }
  489. getRenderTargetDetails( renderTarget ) {
  490. const textures = renderTarget.textures;
  491. const attachments = [];
  492. const getBPC = ( texture ) => {
  493. switch ( texture.type ) {
  494. case ByteType:
  495. case UnsignedByteType:
  496. return '8';
  497. case ShortType:
  498. case UnsignedShortType:
  499. case HalfFloatType:
  500. case UnsignedShort4444Type:
  501. case UnsignedShort5551Type:
  502. return '16';
  503. case IntType:
  504. case UnsignedIntType:
  505. case FloatType:
  506. case UnsignedInt248Type:
  507. case UnsignedInt5999Type:
  508. case UnsignedInt101111Type:
  509. return '32';
  510. default:
  511. return '?';
  512. }
  513. };
  514. const getFormat = ( texture ) => {
  515. switch ( texture.format ) {
  516. case AlphaFormat:
  517. return 'a';
  518. case RedFormat:
  519. case RedIntegerFormat:
  520. return 'r';
  521. case RGFormat:
  522. case RGIntegerFormat:
  523. return 'rg';
  524. case RGBFormat:
  525. case RGBIntegerFormat:
  526. return 'rgb';
  527. case DepthFormat:
  528. return 'depth';
  529. case DepthStencilFormat:
  530. return 'depth-stencil';
  531. case RGBAFormat:
  532. case RGBAIntegerFormat:
  533. default:
  534. return 'rgba';
  535. }
  536. };
  537. for ( let i = 0; i < textures.length; i ++ ) {
  538. const texture = textures[ i ];
  539. const bpc = getBPC( texture );
  540. const format = getFormat( texture );
  541. let description = `[${ i }]`;
  542. if ( texture.name && ! ( texture.isDepthTexture && texture.name === 'depth' ) ) {
  543. description += ` ${ texture.name }`;
  544. }
  545. description += ` ${ format } ${ bpc } bpc`;
  546. attachments.push( description );
  547. }
  548. const details = {
  549. target: renderTarget.name || 'RenderTarget',
  550. [ `attachments(${ textures.length })` ]: '\n' + attachments.join( '\n' )
  551. };
  552. if ( renderTarget.depthTexture ) {
  553. details.depth = `${ getBPC( renderTarget.depthTexture ) } bpc`;
  554. }
  555. return details;
  556. }
  557. getCallDetail( method, args ) {
  558. switch ( method ) {
  559. case 'draw': {
  560. const renderObject = args[ 0 ];
  561. const details = {
  562. object: renderObject.object.name || renderObject.object.type,
  563. material: renderObject.material.name || renderObject.material.type,
  564. geometry: renderObject.geometry.name || renderObject.geometry.type
  565. };
  566. if ( renderObject.getDrawParameters ) {
  567. const drawParams = renderObject.getDrawParameters();
  568. if ( drawParams ) {
  569. if ( renderObject.object.isMesh || renderObject.object.isSprite ) {
  570. details.triangles = drawParams.vertexCount / 3;
  571. if ( renderObject.object.count > 1 ) {
  572. details.instance = renderObject.object.count;
  573. details[ 'triangles per instance' ] = details.triangles;
  574. details.triangles *= details.instance;
  575. }
  576. }
  577. }
  578. }
  579. return details;
  580. }
  581. case 'beginRender': {
  582. const renderContext = args[ 0 ];
  583. const details = {
  584. scene: this.renderer.inspector.currentRender.name || 'unknown',
  585. camera: renderContext.camera.name || renderContext.camera.type
  586. };
  587. if ( renderContext.renderTarget && ! renderContext.renderTarget.isPostProcessingRenderTarget ) {
  588. Object.assign( details, this.getRenderTargetDetails( renderContext.renderTarget ) );
  589. } else {
  590. details.target = 'CanvasTarget';
  591. }
  592. return details;
  593. }
  594. case 'beginCompute': {
  595. const details = {
  596. compute: this.renderer.inspector.currentCompute.name || 'unknown'
  597. };
  598. return details;
  599. }
  600. case 'compute': {
  601. const computeNode = args[ 1 ];
  602. const bindings = args[ 2 ];
  603. const dispatchSize = args[ 4 ] || computeNode.dispatchSize || computeNode.count;
  604. const node = computeNode.name || computeNode.type || 'unknown';
  605. // bindings
  606. let bindingsCount = 0;
  607. if ( bindings ) {
  608. bindingsCount = bindings.length;
  609. }
  610. // dispatch
  611. let dispatch;
  612. if ( dispatchSize.isIndirectStorageBufferAttribute ) {
  613. dispatch = 'indirect';
  614. } else if ( Array.isArray( dispatchSize ) ) {
  615. dispatch = dispatchSize.join( ', ' );
  616. } else {
  617. dispatch = dispatchSize;
  618. }
  619. // details
  620. return {
  621. node,
  622. bindings: bindingsCount,
  623. dispatch
  624. };
  625. }
  626. case 'updateBinding': {
  627. const binding = args[ 0 ];
  628. return { group: binding.name || 'unknown' };
  629. }
  630. case 'clear': {
  631. const renderContext = args[ 3 ];
  632. const details = {
  633. color: args[ 0 ],
  634. depth: args[ 1 ],
  635. stencil: args[ 2 ]
  636. };
  637. if ( renderContext.renderTarget && ! renderContext.renderTarget.isPostProcessingRenderTarget ) {
  638. const renderTargetDetails = this.getRenderTargetDetails( renderContext.renderTarget );
  639. if ( renderTargetDetails.depth ) {
  640. renderTargetDetails[ 'depth texture' ] = renderTargetDetails.depth;
  641. delete renderTargetDetails.depth;
  642. }
  643. Object.assign( details, renderTargetDetails );
  644. } else {
  645. details.target = 'CanvasTarget';
  646. }
  647. return details;
  648. }
  649. case 'updateViewport': {
  650. const renderContext = args[ 0 ];
  651. const { x, y, width, height } = renderContext.viewportValue;
  652. return { x, y, width, height };
  653. }
  654. case 'updateScissor': {
  655. const renderContext = args[ 0 ];
  656. const { x, y, width, height } = renderContext.scissorValue;
  657. return { x, y, width, height };
  658. }
  659. case 'createProgram':
  660. case 'destroyProgram': {
  661. const program = args[ 0 ];
  662. return { stage: program.stage, name: program.name || 'unknown' };
  663. }
  664. case 'createRenderPipeline': {
  665. const renderObject = args[ 0 ];
  666. const details = {
  667. object: renderObject.object ? ( renderObject.object.name || renderObject.object.type || 'unknown' ) : 'unknown',
  668. material: renderObject.material ? ( renderObject.material.name || renderObject.material.type || 'unknown' ) : 'unknown'
  669. };
  670. return details;
  671. }
  672. case 'createComputePipeline':
  673. case 'destroyComputePipeline': {
  674. const pipeline = args[ 0 ];
  675. return { name: pipeline.name || 'unknown' };
  676. }
  677. case 'createBindings':
  678. case 'updateBindings': {
  679. const bindGroup = args[ 0 ];
  680. const details = {
  681. group: bindGroup.name || 'unknown',
  682. count: bindGroup.bindings.length
  683. };
  684. return details;
  685. }
  686. case 'createUniformBuffer':
  687. case 'destroyUniformBuffer': {
  688. const binding = args[ 0 ];
  689. const details = {
  690. group: binding.groupNode.name || 'unknown',
  691. size: binding.byteLength + ' bytes'
  692. };
  693. if ( binding.name !== details.group ) {
  694. details.name = binding.name;
  695. }
  696. return details;
  697. }
  698. case 'createNodeBuilder': {
  699. const object = args[ 0 ];
  700. const details = { object: object.name || object.type || 'unknown' };
  701. if ( object.material ) {
  702. details.material = object.material.name || object.material.type || 'unknown';
  703. }
  704. return details;
  705. }
  706. case 'createAttribute':
  707. case 'createIndexAttribute':
  708. case 'createStorageAttribute':
  709. case 'destroyAttribute':
  710. case 'destroyIndexAttribute':
  711. case 'destroyStorageAttribute': {
  712. const attribute = args[ 0 ];
  713. const details = {};
  714. if ( attribute.name ) details.name = attribute.name;
  715. if ( attribute.count !== undefined ) details.count = attribute.count;
  716. if ( attribute.itemSize !== undefined ) details.itemSize = attribute.itemSize;
  717. return details;
  718. }
  719. case 'copyFramebufferToTexture': {
  720. const target = args[ 0 ];
  721. const rectangle = args[ 2 ];
  722. const details = {
  723. target: this.getTextureName( target ),
  724. width: rectangle.z,
  725. height: rectangle.w
  726. };
  727. return details;
  728. }
  729. case 'copyTextureToTexture': {
  730. const srcTexture = args[ 0 ];
  731. const dstTexture = args[ 1 ];
  732. const details = {
  733. source: this.getTextureName( srcTexture ),
  734. destination: this.getTextureName( dstTexture )
  735. };
  736. return details;
  737. }
  738. case 'updateSampler': {
  739. const texture = args[ 0 ];
  740. const details = {
  741. magFilter: this.getTextureFilterName( texture.magFilter ),
  742. minFilter: this.getTextureFilterName( texture.minFilter ),
  743. wrapS: this.getTextureWrapName( texture.wrapS ),
  744. wrapT: this.getTextureWrapName( texture.wrapT ),
  745. anisotropy: texture.anisotropy
  746. };
  747. return details;
  748. }
  749. case 'updateTexture':
  750. case 'generateMipmaps':
  751. case 'createTexture':
  752. case 'destroyTexture': {
  753. const texture = args[ 0 ];
  754. const name = this.getTextureName( texture );
  755. const details = { texture: name };
  756. if ( texture.image ) {
  757. if ( texture.image.width !== undefined ) details.width = texture.image.width;
  758. if ( texture.image.height !== undefined ) details.height = texture.image.height;
  759. }
  760. return details;
  761. }
  762. }
  763. return null;
  764. }
  765. getTextureName( texture ) {
  766. if ( texture.name ) return texture.name;
  767. const types = [
  768. 'isFramebufferTexture', 'isDepthTexture', 'isDataArrayTexture',
  769. 'isData3DTexture', 'isDataTexture', 'isCompressedArrayTexture',
  770. 'isCompressedTexture', 'isCubeTexture', 'isVideoTexture',
  771. 'isCanvasTexture', 'isTexture'
  772. ];
  773. for ( const type of types ) {
  774. if ( texture[ type ] ) return type.replace( 'is', '' );
  775. }
  776. return 'Texture';
  777. }
  778. getTextureFilterName( filter ) {
  779. const filters = {
  780. 1003: 'Nearest',
  781. 1004: 'NearestMipmapNearest',
  782. 1005: 'NearestMipmapLinear',
  783. 1006: 'Linear',
  784. 1007: 'LinearMipmapNearest',
  785. 1008: 'LinearMipmapLinear'
  786. };
  787. return filters[ filter ] || filter;
  788. }
  789. getTextureWrapName( wrap ) {
  790. const wrappings = {
  791. 1000: 'Repeat',
  792. 1001: 'ClampToEdge',
  793. 1002: 'MirroredRepeat'
  794. };
  795. return wrappings[ wrap ] || wrap;
  796. }
  797. formatDetails( details ) {
  798. const parts = [];
  799. for ( const key in details ) {
  800. if ( details[ key ] !== undefined ) {
  801. parts.push( `<span style="opacity: 0.5">${key}:</span> <span style="color: var(--text-secondary); opacity: 1">${details[ key ]}</span>` );
  802. }
  803. }
  804. if ( parts.length === 0 ) return '';
  805. return `<span style="font-size: 11px; margin-left: 8px; color: var(--text-secondary); opacity: 1;">{ ${parts.join( '<span style="opacity: 0.5">, </span>' )} }</span>`;
  806. }
  807. renderSlider() {
  808. if ( this.frames.length === 0 ) {
  809. this.playhead.style.display = 'none';
  810. this.frameInfo.textContent = '';
  811. return;
  812. }
  813. // Reset graph safely to fit recorded frames exactly up to maxPoints
  814. this.graph.lines[ 'calls' ].points = [];
  815. this.graph.lines[ 'fps' ].points = [];
  816. this.graph.lines[ 'triangles' ].points = [];
  817. this.graph.resetLimit();
  818. // If recorded frames exceed SVG Graph maxPoints, we sample/slice it
  819. // (Graph.js inherently handles shifting for real-time,
  820. // but statically we want to visualize as much up to max bounds)
  821. let framesToRender = this.frames;
  822. if ( framesToRender.length > this.graph.maxPoints ) {
  823. framesToRender = framesToRender.slice( - this.graph.maxPoints );
  824. this.frames = framesToRender; // Adjust our internal array to match what's visible
  825. }
  826. let maxTriangles = 0;
  827. for ( let i = 0; i < framesToRender.length; i ++ ) {
  828. const t = framesToRender[ i ].triangles || 0;
  829. if ( t > maxTriangles ) {
  830. maxTriangles = t;
  831. }
  832. }
  833. for ( let i = 0; i < framesToRender.length; i ++ ) {
  834. const t = framesToRender[ i ].triangles || 0;
  835. const normalizedTriangles = maxTriangles > 0 ? ( t / maxTriangles ) * TRIANGLES_GRAPH_LIMIT : 0;
  836. // Adding calls length to the Graph SVG to visualize workload geometry
  837. this.graph.addPoint( 'calls', framesToRender[ i ].calls.length );
  838. this.graph.addPoint( 'fps', framesToRender[ i ].fps || 0 );
  839. this.graph.addPoint( 'triangles', normalizedTriangles );
  840. }
  841. this.graph.update();
  842. this.playhead.style.display = 'block';
  843. // Select the previously selected frame, or the last one if tracking, or 0
  844. let targetFrame = 0;
  845. if ( this.selectedFrameIndex !== - 1 && this.selectedFrameIndex < this.frames.length ) {
  846. targetFrame = this.selectedFrameIndex;
  847. } else if ( this.frames.length > 0 ) {
  848. targetFrame = this.frames.length - 1;
  849. }
  850. this.selectFrame( targetFrame );
  851. }
  852. selectFrame( index ) {
  853. if ( this.isRecording ) return;
  854. if ( index < 0 || index >= this.frames.length ) return;
  855. this.selectedFrameIndex = index;
  856. const frame = this.frames[ index ];
  857. this.renderTimelineTrack( frame );
  858. // Update UI texts
  859. const isCompact = this.profiler.panel.offsetWidth < 800;
  860. const frameLabel = isCompact ? '' : 'Frame: ';
  861. const fpsSuffix = isCompact ? '' : ' FPS';
  862. const callsSuffix = isCompact ? '' : ' calls';
  863. const trianglesSuffix = isCompact ? '' : ' triangles';
  864. const group = ( c, text ) => `<span style="display:inline-flex;align-items:center;margin-left:12px;flex-shrink:0;"><span style="width:6px;height:6px;border-radius:50%;background-color:${c};margin-right:6px;flex-shrink:0;"></span>${text}</span>`;
  865. const maxTriangles = Math.max( this.baseTriangles, frame.triangles || 0 );
  866. const trianglesText = isCompact ? ( frame.triangles || 0 ) : ( frame.triangles || 0 ) + ' / ' + maxTriangles + trianglesSuffix;
  867. this.frameInfo.innerHTML = frameLabel + frame.id + group( 'var(--color-fps)', ( frame.fps || 0 ).toFixed( 1 ) + fpsSuffix ) + group( 'var(--color-call)', frame.calls.length + callsSuffix ) + group( 'var(--color-red)', trianglesText );
  868. // Update playhead position
  869. const rect = this.graphSlider.getBoundingClientRect();
  870. const pointCount = this.graph.lines[ 'calls' ].points.length;
  871. if ( pointCount > 0 ) {
  872. // Calculate point width step
  873. const pointStep = rect.width / ( this.graph.maxPoints - 1 );
  874. let localIndex = index;
  875. if ( this.frames.length > pointCount ) {
  876. localIndex = index - ( this.frames.length - pointCount );
  877. }
  878. // x offset calculation from SVG update logic
  879. // The graph translates (slides) back if points length < maxPoints
  880. // which means point 0 is at offset
  881. const offset = rect.width - ( ( pointCount - 1 ) * pointStep );
  882. let xPos = offset + ( localIndex * pointStep );
  883. xPos = Math.max( 1, Math.min( xPos, rect.width - 1 ) );
  884. this.playhead.style.left = xPos + 'px';
  885. this.playhead.style.display = 'block';
  886. }
  887. }
  888. getCallBlock( call, fallbackIndex, instanceIndex = 0 ) {
  889. const target = call.target;
  890. let block;
  891. if ( target && typeof target === 'object' ) {
  892. let blocks = this.callBlocks.get( target );
  893. if ( ! blocks ) {
  894. blocks = [];
  895. this.callBlocks.set( target, blocks );
  896. }
  897. block = blocks[ instanceIndex ];
  898. } else {
  899. block = this.fallbackBlocks[ fallbackIndex ];
  900. }
  901. if ( ! block ) {
  902. block = document.createElement( 'div' );
  903. block.style.display = 'flex';
  904. block.style.alignItems = 'center';
  905. block.style.padding = '4px 8px';
  906. block.style.margin = '2px 0';
  907. block.style.backgroundColor = 'rgba(255, 255, 255, 0.03)';
  908. block.style.fontFamily = 'monospace';
  909. block.style.fontSize = '12px';
  910. block.style.color = 'var(--text-primary)';
  911. block.style.overflow = 'hidden';
  912. block.arrow = document.createElement( 'span' );
  913. block.arrow.style.fontSize = '10px';
  914. block.arrow.style.marginRight = '8px';
  915. block.arrow.style.cursor = 'pointer';
  916. block.arrow.style.width = '35px';
  917. block.arrow.style.textAlign = 'center';
  918. block.arrow.style.flexShrink = '0';
  919. block.arrow.style.whiteSpace = 'nowrap';
  920. block.appendChild( block.arrow );
  921. block.titleSpan = document.createElement( 'span' );
  922. block.titleSpan.style.flex = '1';
  923. block.titleSpan.style.minWidth = '0';
  924. block.titleSpan.style.overflow = 'hidden';
  925. block.titleSpan.style.textOverflow = 'ellipsis';
  926. block.titleSpan.style.whiteSpace = 'nowrap';
  927. block.appendChild( block.titleSpan );
  928. block.addEventListener( 'click', ( e ) => {
  929. if ( ! block._groupId ) return;
  930. e.stopPropagation();
  931. if ( this.collapsedGroups.has( block._groupId ) ) {
  932. this.collapsedGroups.delete( block._groupId );
  933. } else {
  934. this.collapsedGroups.add( block._groupId );
  935. }
  936. this.renderTimelineTrack( this.frames[ this.selectedFrameIndex ] );
  937. } );
  938. if ( target && typeof target === 'object' ) {
  939. this.callBlocks.get( target )[ instanceIndex ] = block;
  940. } else {
  941. this.fallbackBlocks[ fallbackIndex ] = block;
  942. }
  943. }
  944. block.style.cursor = 'default';
  945. block._groupId = null;
  946. block.arrow.style.display = 'none';
  947. return block;
  948. }
  949. renderTimelineTrack( frame ) {
  950. if ( this.isRecording ) return;
  951. if ( ! frame || frame.calls.length === 0 ) {
  952. this.timelineTrack.innerHTML = '';
  953. return;
  954. }
  955. // Track collapsed states
  956. if ( ! this.collapsedGroups ) {
  957. this.collapsedGroups = new Set();
  958. }
  959. let blockIndex = 0;
  960. const trackChildren = this.timelineTrack.children;
  961. let childIndex = 0;
  962. const instanceCounts = new WeakMap();
  963. if ( this.isHierarchicalView ) {
  964. const groupedCalls = [];
  965. let currentGroup = null;
  966. for ( let i = 0; i < frame.calls.length; i ++ ) {
  967. const call = frame.calls[ i ];
  968. const isStructural = call.method.startsWith( 'begin' ) || call.method.startsWith( 'finish' );
  969. const formatedDetails = call.details ? this.formatDetails( call.details ) : '';
  970. if ( currentGroup && currentGroup.method === call.method && currentGroup.formatedDetails === formatedDetails && ! isStructural ) {
  971. currentGroup.count ++;
  972. } else {
  973. currentGroup = { method: call.method, count: 1, formatedDetails, target: call.target, details: call.details };
  974. groupedCalls.push( currentGroup );
  975. }
  976. }
  977. let currentIndent = 0;
  978. const indentSize = 24;
  979. // Stack to keep track of parent elements and their collapsed state
  980. const elementStack = [ { element: this.timelineTrack, isCollapsed: false, id: '', beginCount: 0 } ];
  981. for ( let i = 0; i < groupedCalls.length; i ++ ) {
  982. const call = groupedCalls[ i ];
  983. let instanceIndex = 0;
  984. if ( call.target && typeof call.target === 'object' ) {
  985. instanceIndex = instanceCounts.get( call.target ) || 0;
  986. instanceCounts.set( call.target, instanceIndex + 1 );
  987. }
  988. const block = this.getCallBlock( call, blockIndex ++, instanceIndex );
  989. block.style.marginLeft = ( currentIndent * indentSize ) + 'px';
  990. block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
  991. // Clean up any old info-icon directly under block
  992. const directInfoIcon = block.querySelector( ':scope > .info-icon' );
  993. if ( directInfoIcon ) {
  994. directInfoIcon.remove();
  995. }
  996. // Build titleSpan content
  997. block.titleSpan.textContent = '';
  998. const methodSpan = document.createElement( 'span' );
  999. methodSpan.textContent = call.method;
  1000. block.titleSpan.appendChild( methodSpan );
  1001. if ( call.details ) {
  1002. let tooltipText = `### ${call.method}\n`;
  1003. for ( const key in call.details ) {
  1004. if ( call.details[ key ] !== undefined ) {
  1005. tooltipText += `**${key}**: ${call.details[ key ]}\n`;
  1006. }
  1007. }
  1008. const infoIcon = info( block.titleSpan, tooltipText );
  1009. infoIcon.style.flexShrink = '0';
  1010. infoIcon.style.marginLeft = '6px';
  1011. infoIcon.style.display = 'inline-flex';
  1012. infoIcon.style.verticalAlign = 'middle';
  1013. }
  1014. const detailsAndCountSpan = document.createElement( 'span' );
  1015. let detailsAndCountHTML = ( call.formatedDetails ? call.formatedDetails : '' );
  1016. if ( call.count > 1 ) {
  1017. detailsAndCountHTML += ` <span style="opacity: 0.5">( ${call.count} )</span>`;
  1018. }
  1019. if ( detailsAndCountHTML ) {
  1020. detailsAndCountSpan.innerHTML = detailsAndCountHTML;
  1021. block.titleSpan.appendChild( detailsAndCountSpan );
  1022. }
  1023. const currentParent = elementStack[ elementStack.length - 1 ];
  1024. // Only add to DOM if parent is not collapsed
  1025. if ( ! currentParent.isCollapsed ) {
  1026. if ( trackChildren[ childIndex ] !== block ) {
  1027. this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] );
  1028. }
  1029. childIndex ++;
  1030. }
  1031. if ( call.method.startsWith( 'begin' ) ) {
  1032. const beginIndex = currentParent.beginCount ++;
  1033. const groupId = currentParent.id + '/' + call.method + '-' + beginIndex;
  1034. const isCollapsed = this.collapsedGroups.has( groupId );
  1035. block._groupId = groupId;
  1036. block.style.cursor = 'pointer';
  1037. block.arrow.style.display = 'inline-block';
  1038. block.arrow.textContent = isCollapsed ? '[ + ]' : '[ - ]';
  1039. currentIndent ++;
  1040. elementStack.push( { element: block, isCollapsed: currentParent.isCollapsed || isCollapsed, id: groupId, beginCount: 0 } );
  1041. } else if ( call.method.startsWith( 'finish' ) ) {
  1042. currentIndent = Math.max( 0, currentIndent - 1 );
  1043. elementStack.pop();
  1044. }
  1045. }
  1046. } else {
  1047. const callCounts = {};
  1048. for ( let i = 0; i < frame.calls.length; i ++ ) {
  1049. const method = frame.calls[ i ].method;
  1050. if ( method.startsWith( 'finish' ) ) continue;
  1051. callCounts[ method ] = ( callCounts[ method ] || 0 ) + 1;
  1052. }
  1053. const sortedCalls = Object.keys( callCounts ).map( method => ( { method, count: callCounts[ method ] } ) );
  1054. sortedCalls.sort( ( a, b ) => b.count - a.count );
  1055. for ( let i = 0; i < sortedCalls.length; i ++ ) {
  1056. const call = sortedCalls[ i ];
  1057. const block = this.getCallBlock( call, blockIndex ++ );
  1058. block.style.marginLeft = '0px';
  1059. block.style.borderLeft = '4px solid ' + this.getColorForMethod( call.method );
  1060. const infoIcon = block.querySelector( '.info-icon' );
  1061. if ( infoIcon ) {
  1062. infoIcon.remove();
  1063. }
  1064. block.titleSpan.innerHTML = call.method + ( call.count > 1 ? ` <span style="opacity: 0.5">( ${call.count} )</span>` : '' );
  1065. if ( trackChildren[ childIndex ] !== block ) {
  1066. this.timelineTrack.insertBefore( block, trackChildren[ childIndex ] );
  1067. }
  1068. childIndex ++;
  1069. }
  1070. }
  1071. while ( this.timelineTrack.children.length > childIndex ) {
  1072. this.timelineTrack.removeChild( this.timelineTrack.lastChild );
  1073. }
  1074. }
  1075. getColorForMethod( method ) {
  1076. if ( method.startsWith( 'begin' ) ) return 'var(--color-green)';
  1077. if ( method.startsWith( 'finish' ) || method.startsWith( 'destroy' ) ) return 'var(--color-red)';
  1078. if ( method.startsWith( 'draw' ) || method.startsWith( 'compute' ) || method.startsWith( 'create' ) || method.startsWith( 'generate' ) ) return 'var(--color-yellow)';
  1079. return 'var(--text-secondary)';
  1080. }
  1081. }
  1082. export { Timeline };
粤ICP备19079148号