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