Split /resources into /resources/lib and /resources/src
[lhc/web/wiklou.git] / resources / src / mediawiki / mediawiki.debug.profile.js
1 /*!
2 * JavaScript for the debug toolbar profiler, enabled through $wgDebugToolbar
3 * and StartProfiler.php.
4 *
5 * @author Erik Bernhardson
6 * @since 1.23
7 */
8
9 ( function ( mw, $ ) {
10 'use strict';
11
12 /**
13 * @singleton
14 * @class mw.Debug.profile
15 */
16 var profile = mw.Debug.profile = {
17 /**
18 * Object containing data for the debug toolbar
19 *
20 * @property ProfileData
21 */
22 data: null,
23
24 /**
25 * @property DOMElement
26 */
27 container: null,
28
29 /**
30 * Initializes the profiling pane.
31 */
32 init: function ( data, width, mergeThresholdPx, dropThresholdPx ) {
33 data = data || mw.config.get( 'debugInfo' ).profile;
34 profile.width = width || $(window).width() - 20;
35 // merge events from same pixel(some events are very granular)
36 mergeThresholdPx = mergeThresholdPx || 2;
37 // only drop events if requested
38 dropThresholdPx = dropThresholdPx || 0;
39
40 if ( !Array.prototype.map || !Array.prototype.reduce || !Array.prototype.filter ) {
41 profile.container = profile.buildRequiresES5();
42 } else if ( data.length === 0 ) {
43 profile.container = profile.buildNoData();
44 } else {
45 // generate a flyout
46 profile.data = new ProfileData( data, profile.width, mergeThresholdPx, dropThresholdPx );
47 // draw it
48 profile.container = profile.buildSvg( profile.container );
49 profile.attachFlyout();
50 }
51
52 return profile.container;
53 },
54
55 buildRequiresES5: function () {
56 return $( '<div>' )
57 .text( 'An ES5 compatible javascript engine is required for the profile visualization.' )
58 .get( 0 );
59 },
60
61 buildNoData: function () {
62 return $( '<div>' ).addClass( 'mw-debug-profile-no-data' )
63 .text( 'No events recorded, ensure profiling is enabled in StartProfiler.php.' )
64 .get( 0 );
65 },
66
67 /**
68 * Creates DOM nodes appropriately namespaced for SVG.
69 *
70 * @param string tag to create
71 * @return DOMElement
72 */
73 createSvgElement: document.createElementNS.bind( document, 'http://www.w3.org/2000/svg' ),
74
75 /**
76 * @param DOMElement|undefined
77 */
78 buildSvg: function ( node ) {
79 var container, group, i, g,
80 timespan = profile.data.timespan,
81 gapPerEvent = 38,
82 space = 10.5,
83 currentHeight = space,
84 totalHeight = 0;
85
86 profile.ratio = ( profile.width - space * 2 ) / ( timespan.end - timespan.start );
87 totalHeight += gapPerEvent * profile.data.groups.length;
88
89 if ( node ) {
90 $( node ).empty();
91 } else {
92 node = profile.createSvgElement( 'svg' );
93 node.setAttribute( 'version', '1.2' );
94 node.setAttribute( 'baseProfile', 'tiny' );
95 }
96 node.style.height = totalHeight;
97 node.style.width = profile.width;
98
99 // use a container that can be transformed
100 container = profile.createSvgElement( 'g' );
101 node.appendChild( container );
102
103 for ( i = 0; i < profile.data.groups.length; i++ ) {
104 group = profile.data.groups[i];
105 g = profile.buildTimeline( group );
106
107 g.setAttribute( 'transform', 'translate( 0 ' + currentHeight + ' )' );
108 container.appendChild( g );
109
110 currentHeight += gapPerEvent;
111 }
112
113 return node;
114 },
115
116 /**
117 * @param Object group of periods to transform into graphics
118 */
119 buildTimeline: function ( group ) {
120 var text, tspan, line, i,
121 sum = group.timespan.sum,
122 ms = ' ~ ' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms',
123 timeline = profile.createSvgElement( 'g' );
124
125 timeline.setAttribute( 'class', 'mw-debug-profile-timeline' );
126
127 // draw label
128 text = profile.createSvgElement( 'text' );
129 text.setAttribute( 'x', profile.xCoord( group.timespan.start ) );
130 text.setAttribute( 'y', 0 );
131 text.textContent = group.name;
132 timeline.appendChild( text );
133
134 // draw metadata
135 tspan = profile.createSvgElement( 'tspan' );
136 tspan.textContent = ms;
137 text.appendChild( tspan );
138
139 // draw timeline periods
140 for ( i = 0; i < group.periods.length; i++ ) {
141 timeline.appendChild( profile.buildPeriod( group.periods[i] ) );
142 }
143
144 // full-width line under each timeline
145 line = profile.createSvgElement( 'line' );
146 line.setAttribute( 'class', 'mw-debug-profile-underline' );
147 line.setAttribute( 'x1', 0 );
148 line.setAttribute( 'y1', 28 );
149 line.setAttribute( 'x2', profile.width );
150 line.setAttribute( 'y2', 28 );
151 timeline.appendChild( line );
152
153 return timeline;
154 },
155
156 /**
157 * @param Object period to transform into graphics
158 */
159 buildPeriod: function ( period ) {
160 var node,
161 head = profile.xCoord( period.start ),
162 tail = profile.xCoord( period.end ),
163 g = profile.createSvgElement( 'g' );
164
165 g.setAttribute( 'class', 'mw-debug-profile-period' );
166 $( g ).data( 'period', period );
167
168 if ( head + 16 > tail ) {
169 node = profile.createSvgElement( 'rect' );
170 node.setAttribute( 'x', head );
171 node.setAttribute( 'y', 8 );
172 node.setAttribute( 'width', 2 );
173 node.setAttribute( 'height', 9 );
174 g.appendChild( node );
175
176 node = profile.createSvgElement( 'rect' );
177 node.setAttribute( 'x', head );
178 node.setAttribute( 'y', 8 );
179 node.setAttribute( 'width', ( period.end - period.start ) * profile.ratio || 2 );
180 node.setAttribute( 'height', 6 );
181 g.appendChild( node );
182 } else {
183 node = profile.createSvgElement( 'polygon' );
184 node.setAttribute( 'points', pointList( [
185 [ head, 8 ],
186 [ head, 19 ],
187 [ head + 8, 8 ],
188 [ head, 8]
189 ] ) );
190 g.appendChild( node );
191
192 node = profile.createSvgElement( 'polygon' );
193 node.setAttribute( 'points', pointList( [
194 [ tail, 8 ],
195 [ tail, 19 ],
196 [ tail - 8, 8 ],
197 [ tail, 8 ],
198 ] ) );
199 g.appendChild( node );
200
201 node = profile.createSvgElement( 'line' );
202 node.setAttribute( 'x1', head );
203 node.setAttribute( 'y1', 9 );
204 node.setAttribute( 'x2', tail );
205 node.setAttribute( 'y2', 9 );
206 g.appendChild( node );
207 }
208
209 return g;
210 },
211
212 /**
213 * @param Object
214 */
215 buildFlyout: function ( period ) {
216 var contained, sum, ms, mem, i,
217 node = $( '<div>' );
218
219 for ( i = 0; i < period.contained.length; i++ ) {
220 contained = period.contained[i];
221 sum = contained.end - contained.start;
222 ms = '' + ( sum < 1 ? sum.toFixed( 2 ) : sum.toFixed( 0 ) ) + ' ms';
223 mem = formatBytes( contained.memory );
224
225 $( '<div>' ).text( contained.source.name )
226 .append( $( '<span>' ).text( ' ~ ' + ms + ' / ' + mem ).addClass( 'mw-debug-profile-meta' ) )
227 .appendTo( node );
228 }
229
230 return node;
231 },
232
233 /**
234 * Attach a hover flyout to all .mw-debug-profile-period groups.
235 */
236 attachFlyout: function () {
237 // for some reason addClass and removeClass from jQuery
238 // arn't working on svg elements in chrome <= 33.0 (possibly more)
239 var $container = $( profile.container ),
240 addClass = function ( node, value ) {
241 var current = node.getAttribute( 'class' ),
242 list = current ? current.split( ' ' ) : false,
243 idx = list ? list.indexOf( value ) : -1;
244
245 if ( idx === -1 ) {
246 node.setAttribute( 'class', current ? ( current + ' ' + value ) : value );
247 }
248 },
249 removeClass = function ( node, value ) {
250 var current = node.getAttribute( 'class' ),
251 list = current ? current.split( ' ' ) : false,
252 idx = list ? list.indexOf( value ) : -1;
253
254 if ( idx !== -1 ) {
255 list.splice( idx, 1 );
256 node.setAttribute( 'class', list.join( ' ' ) );
257 }
258 },
259 // hide all tipsy flyouts
260 hide = function () {
261 $container.find( '.mw-debug-profile-period.tipsy-visible' )
262 .each( function () {
263 removeClass( this, 'tipsy-visible' );
264 $( this ).tipsy( 'hide' );
265 } );
266 };
267
268 $container.find( '.mw-debug-profile-period' ).tipsy( {
269 fade: true,
270 gravity: function () {
271 return $.fn.tipsy.autoNS.call( this )
272 + $.fn.tipsy.autoWE.call( this );
273 },
274 className: 'mw-debug-profile-tipsy',
275 center: false,
276 html: true,
277 trigger: 'manual',
278 title: function () {
279 return profile.buildFlyout( $( this ).data( 'period' ) ).html();
280 },
281 } ).on( 'mouseenter', function () {
282 hide();
283 addClass( this, 'tipsy-visible' );
284 $( this ).tipsy( 'show' );
285 } );
286
287 $container.on( 'mouseleave', function ( event ) {
288 var $from = $( event.relatedTarget ),
289 $to = $( event.target );
290 // only close the tipsy if we are not
291 if ( $from.closest( '.tipsy' ).length === 0 &&
292 $to.closest( '.tipsy' ).length === 0 &&
293 $to.get( 0 ).namespaceURI !== 'http://www.w4.org/2000/svg'
294 ) {
295 hide();
296 }
297 } ).on( 'click', function () {
298 // convenience method for closing
299 hide();
300 } );
301 },
302
303 /**
304 * @return number the x co-ordinate for the specified timestamp
305 */
306 xCoord: function ( msTimestamp ) {
307 return ( msTimestamp - profile.data.timespan.start ) * profile.ratio;
308 },
309 };
310
311 function ProfileData( data, width, mergeThresholdPx, dropThresholdPx ) {
312 // validate input data
313 this.data = data.map( function ( event ) {
314 event.periods = event.periods.filter( function ( period ) {
315 return period.start && period.end
316 && period.start < period.end
317 // period start must be a reasonable ms timestamp
318 && period.start > 1000000;
319 } );
320 return event;
321 } ).filter( function ( event ) {
322 return event.name && event.periods.length > 0;
323 } );
324
325 // start and end time of the data
326 this.timespan = this.data.reduce( function ( result, event ) {
327 return event.periods.reduce( periodMinMax, result );
328 }, periodMinMax.initial() );
329
330 // transform input data
331 this.groups = this.collate( width, mergeThresholdPx, dropThresholdPx );
332
333 return this;
334 }
335
336 /**
337 * There are too many unique events to display a line for each,
338 * so this does a basic grouping.
339 */
340 ProfileData.groupOf = function ( label ) {
341 var pos, prefix = 'Profile section ended by close(): ';
342 if ( label.indexOf( prefix ) === 0 ) {
343 label = label.substring( prefix.length );
344 }
345
346 pos = [ '::', ':', '-' ].reduce( function ( result, separator ) {
347 var pos = label.indexOf( separator );
348 if ( pos === -1 ) {
349 return result;
350 } else if ( result === -1 ) {
351 return pos;
352 } else {
353 return Math.min( result, pos );
354 }
355 }, -1 );
356
357 if ( pos === -1 ) {
358 return label;
359 } else {
360 return label.substring( 0, pos );
361 }
362 };
363
364 /**
365 * @return Array list of objects with `name` and `events` keys
366 */
367 ProfileData.groupEvents = function ( events ) {
368 var group, i,
369 groups = {};
370
371 // Group events together
372 for ( i = events.length - 1; i >= 0; i-- ) {
373 group = ProfileData.groupOf( events[i].name );
374 if ( groups[group] ) {
375 groups[group].push( events[i] );
376 } else {
377 groups[group] = [events[i]];
378 }
379 }
380
381 // Return an array of groups
382 return Object.keys( groups ).map( function ( group ) {
383 return {
384 name: group,
385 events: groups[group],
386 };
387 } );
388 };
389
390 ProfileData.periodSorter = function ( a, b ) {
391 if ( a.start === b.start ) {
392 return a.end - b.end;
393 }
394 return a.start - b.start;
395 };
396
397 ProfileData.genMergePeriodReducer = function ( mergeThresholdMs ) {
398 return function ( result, period ) {
399 if ( result.length === 0 ) {
400 // period is first result
401 return [{
402 start: period.start,
403 end: period.end,
404 contained: [period],
405 }];
406 }
407 var last = result[result.length - 1];
408 if ( period.end < last.end ) {
409 // end is contained within previous
410 result[result.length - 1].contained.push( period );
411 } else if ( period.start - mergeThresholdMs < last.end ) {
412 // neighbors within merging distance
413 result[result.length - 1].end = period.end;
414 result[result.length - 1].contained.push( period );
415 } else {
416 // period is next result
417 result.push({
418 start: period.start,
419 end: period.end,
420 contained: [period],
421 });
422 }
423 return result;
424 };
425 };
426
427 /**
428 * Collect all periods from the grouped events and apply merge and
429 * drop transformations
430 */
431 ProfileData.extractPeriods = function ( events, mergeThresholdMs, dropThresholdMs ) {
432 // collect the periods from all events
433 return events.reduce( function ( result, event ) {
434 if ( !event.periods.length ) {
435 return result;
436 }
437 result.push.apply( result, event.periods.map( function ( period ) {
438 // maintain link from period to event
439 period.source = event;
440 return period;
441 } ) );
442 return result;
443 }, [] )
444 // sort combined periods
445 .sort( ProfileData.periodSorter )
446 // Apply merge threshold. Original periods
447 // are maintained in the `contained` property
448 .reduce( ProfileData.genMergePeriodReducer( mergeThresholdMs ), [] )
449 // Apply drop threshold
450 .filter( function ( period ) {
451 return period.end - period.start > dropThresholdMs;
452 } );
453 };
454
455 /**
456 * runs a callback on all periods in the group. Only valid after
457 * groups.periods[0..n].contained are populated. This runs against
458 * un-transformed data and is better suited to summing or other
459 * stat collection
460 */
461 ProfileData.reducePeriods = function ( group, callback, result ) {
462 return group.periods.reduce( function ( result, period ) {
463 return period.contained.reduce( callback, result );
464 }, result );
465 };
466
467 /**
468 * Transforms this.data grouping by labels, merging neighboring
469 * events in the groups, and drops events and groups below the
470 * display threshold. Groups are returned sorted by starting time.
471 */
472 ProfileData.prototype.collate = function ( width, mergeThresholdPx, dropThresholdPx ) {
473 // ms to pixel ratio
474 var ratio = ( this.timespan.end - this.timespan.start ) / width,
475 // transform thresholds to ms
476 mergeThresholdMs = mergeThresholdPx * ratio,
477 dropThresholdMs = dropThresholdPx * ratio;
478
479 return ProfileData.groupEvents( this.data )
480 // generate data about the grouped events
481 .map( function ( group ) {
482 // Cleaned periods from all events
483 group.periods = ProfileData.extractPeriods( group.events, mergeThresholdMs, dropThresholdMs );
484 // min and max timestamp per group
485 group.timespan = ProfileData.reducePeriods( group, periodMinMax, periodMinMax.initial() );
486 // ms from first call to end of last call
487 group.timespan.length = group.timespan.end - group.timespan.start;
488 // collect the un-transformed periods
489 group.timespan.sum = ProfileData.reducePeriods( group, function ( result, period ) {
490 result.push( period );
491 return result;
492 }, [] )
493 // sort by start time
494 .sort( ProfileData.periodSorter )
495 // merge overlapping
496 .reduce( ProfileData.genMergePeriodReducer( 0 ), [] )
497 // sum
498 .reduce( function ( result, period ) {
499 return result + period.end - period.start;
500 }, 0 );
501
502 return group;
503 }, this )
504 // remove groups that have had all their periods filtered
505 .filter( function ( group ) {
506 return group.periods.length > 0;
507 } )
508 // sort events by first start
509 .sort( function ( a, b ) {
510 return ProfileData.periodSorter( a.timespan, b.timespan );
511 } );
512 };
513
514 // reducer to find edges of period array
515 function periodMinMax( result, period ) {
516 if ( period.start < result.start ) {
517 result.start = period.start;
518 }
519 if ( period.end > result.end ) {
520 result.end = period.end;
521 }
522 return result;
523 }
524
525 periodMinMax.initial = function () {
526 return { start: Number.POSITIVE_INFINITY, end: Number.NEGATIVE_INFINITY };
527 };
528
529 function formatBytes( bytes ) {
530 var i, sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
531 if ( bytes === 0 ) {
532 return '0 Bytes';
533 }
534 i = parseInt( Math.floor( Math.log( bytes ) / Math.log( 1024 ) ), 10 );
535 return Math.round( bytes / Math.pow( 1024, i ), 2 ) + ' ' + sizes[i];
536 }
537
538 // turns a 2d array into a point list for svg
539 // polygon points attribute
540 // ex: [[1,2],[3,4],[4,2]] = '1,2 3,4 4,2'
541 function pointList( pairs ) {
542 return pairs.map( function ( pair ) {
543 return pair.join( ',' );
544 } ).join( ' ' );
545 }
546 }( mediaWiki, jQuery ) );