Timeline.js 43 KB

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