time.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. 'use strict';
  2. class TimelineEngine {
  3. /**
  4. * @param {Theme} theme
  5. * @param {Renderer} renderer
  6. * @param {Legend} legend
  7. * @param {Element} threshold
  8. * @param {Object} request
  9. * @param {Number} eventHeight
  10. * @param {Number} horizontalMargin
  11. */
  12. constructor(theme, renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) {
  13. this.theme = theme;
  14. this.renderer = renderer;
  15. this.legend = legend;
  16. this.threshold = threshold;
  17. this.request = request;
  18. this.scale = renderer.width / request.end;
  19. this.eventHeight = eventHeight;
  20. this.horizontalMargin = horizontalMargin;
  21. this.labelY = Math.round(this.eventHeight * 0.48);
  22. this.periodY = Math.round(this.eventHeight * 0.66);
  23. this.FqcnMatcher = /\\([^\\]+)$/i;
  24. this.origin = null;
  25. this.createEventElements = this.createEventElements.bind(this);
  26. this.createBackground = this.createBackground.bind(this);
  27. this.createPeriod = this.createPeriod.bind(this);
  28. this.render = this.render.bind(this);
  29. this.renderEvent = this.renderEvent.bind(this);
  30. this.renderPeriod = this.renderPeriod.bind(this);
  31. this.onResize = this.onResize.bind(this);
  32. this.isActive = this.isActive.bind(this);
  33. this.threshold.addEventListener('change', this.render);
  34. this.legend.addEventListener('change', this.render);
  35. window.addEventListener('resize', this.onResize);
  36. this.createElements();
  37. this.render();
  38. }
  39. onResize() {
  40. this.renderer.measure();
  41. this.setScale(this.renderer.width / this.request.end);
  42. }
  43. setScale(scale) {
  44. if (scale !== this.scale) {
  45. this.scale = scale;
  46. this.render();
  47. }
  48. }
  49. createElements() {
  50. this.origin = this.renderer.setFullVerticalLine(this.createBorder(), 0);
  51. this.renderer.add(this.origin);
  52. this.request.events
  53. .filter(event => event.category === 'section')
  54. .map(this.createBackground)
  55. .forEach(this.renderer.add);
  56. this.request.events
  57. .map(this.createEventElements)
  58. .forEach(this.renderer.add);
  59. }
  60. createBackground(event) {
  61. const subrequest = event.name === '__section__.child';
  62. const background = this.renderer.create('rect', subrequest ? 'timeline-subrequest' : 'timeline-border');
  63. event.elements = Object.assign(event.elements || {}, { background });
  64. return background;
  65. }
  66. createEventElements(event) {
  67. const { name, category, duration, memory, periods } = event;
  68. const border = this.renderer.setFullHorizontalLine(this.createBorder(), 0);
  69. const lines = periods.map(period => this.createPeriod(period, category));
  70. const label = this.createLabel(this.getShortName(name), duration, memory, periods[0]);
  71. const title = this.renderer.createTitle(name);
  72. const group = this.renderer.group([title, border, label].concat(lines), this.theme.getCategoryColor(event.category));
  73. event.elements = Object.assign(event.elements || {}, { group, label, border });
  74. this.legend.add(event.category)
  75. return group;
  76. }
  77. createLabel(name, duration, memory, period) {
  78. const label = this.renderer.createText(name, period.start * this.scale, this.labelY, 'timeline-label');
  79. const sublabel = this.renderer.createTspan(` ${duration} ms / ${memory} MiB`, 'timeline-sublabel');
  80. label.appendChild(sublabel);
  81. return label;
  82. }
  83. createPeriod(period, category) {
  84. const timeline = this.renderer.createPath(null, 'timeline-period', this.theme.getCategoryColor(category));
  85. period.draw = category === 'section' ? this.renderer.setSectionLine : this.renderer.setPeriodLine;
  86. period.elements = Object.assign(period.elements || {}, { timeline });
  87. return timeline;
  88. }
  89. createBorder() {
  90. return this.renderer.createPath(null, 'timeline-border');
  91. }
  92. isActive(event) {
  93. const { duration, category } = event;
  94. return duration >= this.threshold.value && this.legend.isActive(category);
  95. }
  96. render() {
  97. const events = this.request.events.filter(this.isActive);
  98. const width = this.renderer.width + this.horizontalMargin * 2;
  99. const height = this.eventHeight * events.length;
  100. // Set view box
  101. this.renderer.setViewBox(-this.horizontalMargin, 0, width, height);
  102. // Show 0ms origin
  103. this.renderer.setFullVerticalLine(this.origin, 0);
  104. // Render all events
  105. this.request.events.forEach(event => this.renderEvent(event, events.indexOf(event)));
  106. }
  107. renderEvent(event, index) {
  108. const { name, category, duration, memory, periods, elements } = event;
  109. const { group, label, border, background } = elements;
  110. const visible = index >= 0;
  111. group.setAttribute('visibility', visible ? 'visible' : 'hidden');
  112. if (background) {
  113. background.setAttribute('visibility', visible ? 'visible' : 'hidden');
  114. if (visible) {
  115. const [min, max] = this.getEventLimits(event);
  116. this.renderer.setFullRectangle(background, min * this.scale, max * this.scale);
  117. }
  118. }
  119. if (visible) {
  120. // Position the group
  121. group.setAttribute('transform', `translate(0, ${index * this.eventHeight})`);
  122. // Update top border
  123. this.renderer.setFullHorizontalLine(border, 0);
  124. // render label and ensure it doesn't escape the viewport
  125. this.renderLabel(label, event);
  126. // Update periods
  127. periods.forEach(this.renderPeriod);
  128. }
  129. }
  130. renderLabel(label, event) {
  131. const width = this.getLabelWidth(label);
  132. const [min, max] = this.getEventLimits(event);
  133. const alignLeft = (min * this.scale) + width <= this.renderer.width;
  134. label.setAttribute('x', (alignLeft ? min : max) * this.scale);
  135. label.setAttribute('text-anchor', alignLeft ? 'start' : 'end');
  136. }
  137. renderPeriod(period) {
  138. const { elements, start, duration } = period;
  139. period.draw(elements.timeline, start * this.scale, this.periodY, Math.max(duration * this.scale, 1));
  140. }
  141. getLabelWidth(label) {
  142. if (typeof label.width === 'undefined') {
  143. label.width = label.getBBox().width;
  144. }
  145. return label.width;
  146. }
  147. getEventLimits(event) {
  148. if (typeof event.limits === 'undefined') {
  149. const { periods } = event;
  150. event.limits = [
  151. periods[0].start,
  152. periods[periods.length - 1].end
  153. ];
  154. }
  155. return event.limits;
  156. }
  157. getShortName(name) {
  158. const matches = this.FqcnMatcher.exec(name);
  159. if (matches) {
  160. return matches[1];
  161. }
  162. return name;
  163. }
  164. }
  165. class Legend {
  166. constructor(element, theme) {
  167. this.element = element;
  168. this.theme = theme;
  169. this.toggle = this.toggle.bind(this);
  170. this.createCategory = this.createCategory.bind(this);
  171. this.categories = [];
  172. this.theme.getDefaultCategories().forEach(this.createCategory);
  173. }
  174. add(category) {
  175. this.get(category).classList.add('present');
  176. }
  177. createCategory(category) {
  178. const element = document.createElement('button');
  179. element.className = `timeline-category active`;
  180. element.style.borderColor = this.theme.getCategoryColor(category);
  181. element.innerText = category;
  182. element.value = category;
  183. element.type = 'button';
  184. element.addEventListener('click', this.toggle);
  185. this.element.appendChild(element);
  186. this.categories.push(element);
  187. return element;
  188. }
  189. toggle(event) {
  190. event.target.classList.toggle('active');
  191. this.emit('change');
  192. }
  193. isActive(category) {
  194. return this.get(category).classList.contains('active');
  195. }
  196. get(category) {
  197. return this.categories.find(element => element.value === category) || this.createCategory(category);
  198. }
  199. emit(name) {
  200. this.element.dispatchEvent(new Event(name));
  201. }
  202. addEventListener(name, callback) {
  203. this.element.addEventListener(name, callback);
  204. }
  205. removeEventListener(name, callback) {
  206. this.element.removeEventListener(name, callback);
  207. }
  208. }
  209. class SvgRenderer {
  210. /**
  211. * @param {SVGElement} element
  212. */
  213. constructor(element) {
  214. this.ns = 'http://www.w3.org/2000/svg';
  215. this.width = null;
  216. this.viewBox = {};
  217. this.element = element;
  218. this.add = this.add.bind(this);
  219. this.setViewBox(0, 0, 0, 0);
  220. this.measure();
  221. }
  222. setViewBox(x, y, width, height) {
  223. this.viewBox = { x, y, width, height };
  224. this.element.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
  225. }
  226. measure() {
  227. this.width = this.element.getBoundingClientRect().width;
  228. }
  229. add(element) {
  230. this.element.appendChild(element);
  231. }
  232. group(elements, className) {
  233. const group = this.create('g', className);
  234. elements.forEach(element => group.appendChild(element));
  235. return group;
  236. }
  237. setHorizontalLine(element, x, y, width) {
  238. element.setAttribute('d', `M${x},${y} h${width}`);
  239. return element;
  240. }
  241. setVerticalLine(element, x, y, height) {
  242. element.setAttribute('d', `M${x},${y} v${height}`);
  243. return element;
  244. }
  245. setFullHorizontalLine(element, y) {
  246. return this.setHorizontalLine(element, this.viewBox.x, y, this.viewBox.width);
  247. }
  248. setFullVerticalLine(element, x) {
  249. return this.setVerticalLine(element, x, this.viewBox.y, this.viewBox.height);
  250. }
  251. setFullRectangle(element, min, max) {
  252. element.setAttribute('x', min);
  253. element.setAttribute('y', this.viewBox.y);
  254. element.setAttribute('width', max - min);
  255. element.setAttribute('height', this.viewBox.height);
  256. }
  257. setSectionLine(element, x, y, width, height = 4, markerSize = 6) {
  258. const totalHeight = height + markerSize;
  259. const maxMarkerWidth = Math.min(markerSize, width / 2);
  260. const widthWithoutMarker = Math.max(0, width - (maxMarkerWidth * 2));
  261. element.setAttribute('d', `M${x},${y + totalHeight} v${-totalHeight} h${width} v${totalHeight} l${-maxMarkerWidth} ${-markerSize} h${-widthWithoutMarker} Z`);
  262. }
  263. setPeriodLine(element, x, y, width, height = 4, markerWidth = 2, markerHeight = 4) {
  264. const totalHeight = height + markerHeight;
  265. const maxMarkerWidth = Math.min(markerWidth, width);
  266. element.setAttribute('d', `M${x + maxMarkerWidth},${y + totalHeight} h${-maxMarkerWidth} v${-totalHeight} h${width} v${height} h${maxMarkerWidth-width}Z`);
  267. }
  268. createText(content, x, y, className) {
  269. const element = this.create('text', className);
  270. element.setAttribute('x', x);
  271. element.setAttribute('y', y);
  272. element.textContent = content;
  273. return element;
  274. }
  275. createTspan(content, className) {
  276. const element = this.create('tspan', className);
  277. element.textContent = content;
  278. return element;
  279. }
  280. createTitle(content) {
  281. const element = this.create('title');
  282. element.textContent = content;
  283. return element;
  284. }
  285. createPath(path = null, className = null, color = null) {
  286. const element = this.create('path', className);
  287. if (path) {
  288. element.setAttribute('d', path);
  289. }
  290. if (color) {
  291. element.setAttribute('fill', color);
  292. }
  293. return element;
  294. }
  295. create(name, className = null) {
  296. const element = document.createElementNS(this.ns, name);
  297. if (className) {
  298. element.setAttribute('class', className);
  299. }
  300. return element;
  301. }
  302. }
  303. class Theme {
  304. constructor(element) {
  305. this.reservedCategoryColors = {
  306. 'default': '#777',
  307. 'section': '#999',
  308. 'event_listener': '#00b8f5',
  309. 'template': '#66cc00',
  310. 'doctrine': '#ff6633',
  311. 'messenger_middleware': '#bdb81e',
  312. 'controller.argument_value_resolver': '#8c5de6',
  313. 'http_client': '#ffa333',
  314. };
  315. this.customCategoryColors = [
  316. '#dbab09', // dark yellow
  317. '#ea4aaa', // pink
  318. '#964b00', // brown
  319. '#22863a', // dark green
  320. '#0366d6', // dark blue
  321. '#17a2b8', // teal
  322. ];
  323. this.getCategoryColor = this.getCategoryColor.bind(this);
  324. this.getDefaultCategories = this.getDefaultCategories.bind(this);
  325. }
  326. getDefaultCategories() {
  327. return Object.keys(this.reservedCategoryColors);
  328. }
  329. getCategoryColor(category) {
  330. return this.reservedCategoryColors[category] || this.getRandomColor(category);
  331. }
  332. getRandomColor(category) {
  333. // instead of pure randomness, colors are assigned deterministically based on the
  334. // category name, to ensure that each custom category always displays the same color
  335. return this.customCategoryColors[this.hash(category) % this.customCategoryColors.length];
  336. }
  337. // copied from https://github.com/darkskyapp/string-hash
  338. hash(string) {
  339. var hash = 5381;
  340. var i = string.length;
  341. while(i) {
  342. hash = (hash * 33) ^ string.charCodeAt(--i);
  343. }
  344. return hash >>> 0;
  345. }
  346. }