123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457 |
- 'use strict';
- class TimelineEngine {
- /**
- * @param {Theme} theme
- * @param {Renderer} renderer
- * @param {Legend} legend
- * @param {Element} threshold
- * @param {Object} request
- * @param {Number} eventHeight
- * @param {Number} horizontalMargin
- */
- constructor(theme, renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) {
- this.theme = theme;
- this.renderer = renderer;
- this.legend = legend;
- this.threshold = threshold;
- this.request = request;
- this.scale = renderer.width / request.end;
- this.eventHeight = eventHeight;
- this.horizontalMargin = horizontalMargin;
- this.labelY = Math.round(this.eventHeight * 0.48);
- this.periodY = Math.round(this.eventHeight * 0.66);
- this.FqcnMatcher = /\\([^\\]+)$/i;
- this.origin = null;
- this.createEventElements = this.createEventElements.bind(this);
- this.createBackground = this.createBackground.bind(this);
- this.createPeriod = this.createPeriod.bind(this);
- this.render = this.render.bind(this);
- this.renderEvent = this.renderEvent.bind(this);
- this.renderPeriod = this.renderPeriod.bind(this);
- this.onResize = this.onResize.bind(this);
- this.isActive = this.isActive.bind(this);
- this.threshold.addEventListener('change', this.render);
- this.legend.addEventListener('change', this.render);
- window.addEventListener('resize', this.onResize);
- this.createElements();
- this.render();
- }
- onResize() {
- this.renderer.measure();
- this.setScale(this.renderer.width / this.request.end);
- }
- setScale(scale) {
- if (scale !== this.scale) {
- this.scale = scale;
- this.render();
- }
- }
- createElements() {
- this.origin = this.renderer.setFullVerticalLine(this.createBorder(), 0);
- this.renderer.add(this.origin);
- this.request.events
- .filter(event => event.category === 'section')
- .map(this.createBackground)
- .forEach(this.renderer.add);
- this.request.events
- .map(this.createEventElements)
- .forEach(this.renderer.add);
- }
- createBackground(event) {
- const subrequest = event.name === '__section__.child';
- const background = this.renderer.create('rect', subrequest ? 'timeline-subrequest' : 'timeline-border');
- event.elements = Object.assign(event.elements || {}, { background });
- return background;
- }
- createEventElements(event) {
- const { name, category, duration, memory, periods } = event;
- const border = this.renderer.setFullHorizontalLine(this.createBorder(), 0);
- const lines = periods.map(period => this.createPeriod(period, category));
- const label = this.createLabel(this.getShortName(name), duration, memory, periods[0]);
- const title = this.renderer.createTitle(name);
- const group = this.renderer.group([title, border, label].concat(lines), this.theme.getCategoryColor(event.category));
- event.elements = Object.assign(event.elements || {}, { group, label, border });
- this.legend.add(event.category)
- return group;
- }
- createLabel(name, duration, memory, period) {
- const label = this.renderer.createText(name, period.start * this.scale, this.labelY, 'timeline-label');
- const sublabel = this.renderer.createTspan(` ${duration} ms / ${memory} MiB`, 'timeline-sublabel');
- label.appendChild(sublabel);
- return label;
- }
- createPeriod(period, category) {
- const timeline = this.renderer.createPath(null, 'timeline-period', this.theme.getCategoryColor(category));
- period.draw = category === 'section' ? this.renderer.setSectionLine : this.renderer.setPeriodLine;
- period.elements = Object.assign(period.elements || {}, { timeline });
- return timeline;
- }
- createBorder() {
- return this.renderer.createPath(null, 'timeline-border');
- }
- isActive(event) {
- const { duration, category } = event;
- return duration >= this.threshold.value && this.legend.isActive(category);
- }
- render() {
- const events = this.request.events.filter(this.isActive);
- const width = this.renderer.width + this.horizontalMargin * 2;
- const height = this.eventHeight * events.length;
- // Set view box
- this.renderer.setViewBox(-this.horizontalMargin, 0, width, height);
- // Show 0ms origin
- this.renderer.setFullVerticalLine(this.origin, 0);
- // Render all events
- this.request.events.forEach(event => this.renderEvent(event, events.indexOf(event)));
- }
- renderEvent(event, index) {
- const { name, category, duration, memory, periods, elements } = event;
- const { group, label, border, background } = elements;
- const visible = index >= 0;
- group.setAttribute('visibility', visible ? 'visible' : 'hidden');
- if (background) {
- background.setAttribute('visibility', visible ? 'visible' : 'hidden');
- if (visible) {
- const [min, max] = this.getEventLimits(event);
- this.renderer.setFullRectangle(background, min * this.scale, max * this.scale);
- }
- }
- if (visible) {
- // Position the group
- group.setAttribute('transform', `translate(0, ${index * this.eventHeight})`);
- // Update top border
- this.renderer.setFullHorizontalLine(border, 0);
- // render label and ensure it doesn't escape the viewport
- this.renderLabel(label, event);
- // Update periods
- periods.forEach(this.renderPeriod);
- }
- }
- renderLabel(label, event) {
- const width = this.getLabelWidth(label);
- const [min, max] = this.getEventLimits(event);
- const alignLeft = (min * this.scale) + width <= this.renderer.width;
- label.setAttribute('x', (alignLeft ? min : max) * this.scale);
- label.setAttribute('text-anchor', alignLeft ? 'start' : 'end');
- }
- renderPeriod(period) {
- const { elements, start, duration } = period;
- period.draw(elements.timeline, start * this.scale, this.periodY, Math.max(duration * this.scale, 1));
- }
- getLabelWidth(label) {
- if (typeof label.width === 'undefined') {
- label.width = label.getBBox().width;
- }
- return label.width;
- }
- getEventLimits(event) {
- if (typeof event.limits === 'undefined') {
- const { periods } = event;
- event.limits = [
- periods[0].start,
- periods[periods.length - 1].end
- ];
- }
- return event.limits;
- }
- getShortName(name) {
- const matches = this.FqcnMatcher.exec(name);
- if (matches) {
- return matches[1];
- }
- return name;
- }
- }
- class Legend {
- constructor(element, theme) {
- this.element = element;
- this.theme = theme;
- this.toggle = this.toggle.bind(this);
- this.createCategory = this.createCategory.bind(this);
- this.categories = [];
- this.theme.getDefaultCategories().forEach(this.createCategory);
- }
- add(category) {
- this.get(category).classList.add('present');
- }
- createCategory(category) {
- const element = document.createElement('button');
- element.className = `timeline-category active`;
- element.style.borderColor = this.theme.getCategoryColor(category);
- element.innerText = category;
- element.value = category;
- element.type = 'button';
- element.addEventListener('click', this.toggle);
- this.element.appendChild(element);
- this.categories.push(element);
- return element;
- }
- toggle(event) {
- event.target.classList.toggle('active');
- this.emit('change');
- }
- isActive(category) {
- return this.get(category).classList.contains('active');
- }
- get(category) {
- return this.categories.find(element => element.value === category) || this.createCategory(category);
- }
- emit(name) {
- this.element.dispatchEvent(new Event(name));
- }
- addEventListener(name, callback) {
- this.element.addEventListener(name, callback);
- }
- removeEventListener(name, callback) {
- this.element.removeEventListener(name, callback);
- }
- }
- class SvgRenderer {
- /**
- * @param {SVGElement} element
- */
- constructor(element) {
- this.ns = 'http://www.w3.org/2000/svg';
- this.width = null;
- this.viewBox = {};
- this.element = element;
- this.add = this.add.bind(this);
- this.setViewBox(0, 0, 0, 0);
- this.measure();
- }
- setViewBox(x, y, width, height) {
- this.viewBox = { x, y, width, height };
- this.element.setAttribute('viewBox', `${x} ${y} ${width} ${height}`);
- }
- measure() {
- this.width = this.element.getBoundingClientRect().width;
- }
- add(element) {
- this.element.appendChild(element);
- }
- group(elements, className) {
- const group = this.create('g', className);
- elements.forEach(element => group.appendChild(element));
- return group;
- }
- setHorizontalLine(element, x, y, width) {
- element.setAttribute('d', `M${x},${y} h${width}`);
- return element;
- }
- setVerticalLine(element, x, y, height) {
- element.setAttribute('d', `M${x},${y} v${height}`);
- return element;
- }
- setFullHorizontalLine(element, y) {
- return this.setHorizontalLine(element, this.viewBox.x, y, this.viewBox.width);
- }
- setFullVerticalLine(element, x) {
- return this.setVerticalLine(element, x, this.viewBox.y, this.viewBox.height);
- }
- setFullRectangle(element, min, max) {
- element.setAttribute('x', min);
- element.setAttribute('y', this.viewBox.y);
- element.setAttribute('width', max - min);
- element.setAttribute('height', this.viewBox.height);
- }
- setSectionLine(element, x, y, width, height = 4, markerSize = 6) {
- const totalHeight = height + markerSize;
- const maxMarkerWidth = Math.min(markerSize, width / 2);
- const widthWithoutMarker = Math.max(0, width - (maxMarkerWidth * 2));
- element.setAttribute('d', `M${x},${y + totalHeight} v${-totalHeight} h${width} v${totalHeight} l${-maxMarkerWidth} ${-markerSize} h${-widthWithoutMarker} Z`);
- }
- setPeriodLine(element, x, y, width, height = 4, markerWidth = 2, markerHeight = 4) {
- const totalHeight = height + markerHeight;
- const maxMarkerWidth = Math.min(markerWidth, width);
- element.setAttribute('d', `M${x + maxMarkerWidth},${y + totalHeight} h${-maxMarkerWidth} v${-totalHeight} h${width} v${height} h${maxMarkerWidth-width}Z`);
- }
- createText(content, x, y, className) {
- const element = this.create('text', className);
- element.setAttribute('x', x);
- element.setAttribute('y', y);
- element.textContent = content;
- return element;
- }
- createTspan(content, className) {
- const element = this.create('tspan', className);
- element.textContent = content;
- return element;
- }
- createTitle(content) {
- const element = this.create('title');
- element.textContent = content;
- return element;
- }
- createPath(path = null, className = null, color = null) {
- const element = this.create('path', className);
- if (path) {
- element.setAttribute('d', path);
- }
- if (color) {
- element.setAttribute('fill', color);
- }
- return element;
- }
- create(name, className = null) {
- const element = document.createElementNS(this.ns, name);
- if (className) {
- element.setAttribute('class', className);
- }
- return element;
- }
- }
- class Theme {
- constructor(element) {
- this.reservedCategoryColors = {
- 'default': '#777',
- 'section': '#999',
- 'event_listener': '#00b8f5',
- 'template': '#66cc00',
- 'doctrine': '#ff6633',
- 'messenger_middleware': '#bdb81e',
- 'controller.argument_value_resolver': '#8c5de6',
- 'http_client': '#ffa333',
- };
- this.customCategoryColors = [
- '#dbab09', // dark yellow
- '#ea4aaa', // pink
- '#964b00', // brown
- '#22863a', // dark green
- '#0366d6', // dark blue
- '#17a2b8', // teal
- ];
- this.getCategoryColor = this.getCategoryColor.bind(this);
- this.getDefaultCategories = this.getDefaultCategories.bind(this);
- }
- getDefaultCategories() {
- return Object.keys(this.reservedCategoryColors);
- }
- getCategoryColor(category) {
- return this.reservedCategoryColors[category] || this.getRandomColor(category);
- }
- getRandomColor(category) {
- // instead of pure randomness, colors are assigned deterministically based on the
- // category name, to ensure that each custom category always displays the same color
- return this.customCategoryColors[this.hash(category) % this.customCategoryColors.length];
- }
- // copied from https://github.com/darkskyapp/string-hash
- hash(string) {
- var hash = 5381;
- var i = string.length;
- while(i) {
- hash = (hash * 33) ^ string.charCodeAt(--i);
- }
- return hash >>> 0;
- }
- }
|