diff --git a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md
index 6f887b3c33f13..d339b4762d131 100644
--- a/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md
+++ b/src/Symfony/Bundle/WebProfilerBundle/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========
+4.3.0
+-----
+
+ * Replaced the canvas performance graph renderer with an SVG renderer
+
4.1.0
-----
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig
new file mode 100644
index 0000000000000..9575178058fd8
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.css.twig
@@ -0,0 +1,105 @@
+/* Variables */
+
+.sf-profiler-timeline {
+ --color-default: #777;
+ --color-section: #999;
+ --color-event-listener: #00B8F5;
+ --color-template: #66CC00;
+ --color-doctrine: #FF6633;
+ --color-messenger-middleware: #BDB81E;
+ --color-controller-argument-value-resolver: #8c5de6;
+}
+
+/* Legend */
+
+.sf-profiler-timeline .legends .timeline-category {
+ border: none;
+ background: none;
+ border-left: 1em solid transparent;
+ line-height: 1em;
+ margin: 0 1em 0 0;
+ padding: 0 0.5em;
+ display: none;
+ opacity: 0.5;
+}
+
+.sf-profiler-timeline .legends .timeline-category.active {
+ opacity: 1;
+}
+
+.sf-profiler-timeline .legends .timeline-category.present {
+ display: inline-block;
+}
+
+.sf-profiler-timeline .legends .{{ classnames.default|raw }} { border-color: var(--color-default); }
+.sf-profiler-timeline .legends .{{ classnames.section|raw }} { border-color: var(--color-section); }
+.sf-profiler-timeline .legends .{{ classnames.event_listener|raw }} { border-color: var(--color-event-listener); }
+.sf-profiler-timeline .legends .{{ classnames.template|raw }} { border-color: var(--color-template); }
+.sf-profiler-timeline .legends .{{ classnames.doctrine|raw }} { border-color: var(--color-doctrine); }
+.sf-profiler-timeline .legends .{{ classnames['messenger.middleware']|raw }} { border-color: var(--color-messenger-middleware); }
+.sf-profiler-timeline .legends .{{ classnames['controller.argument_value_resolver']|raw }} { border-color: var(--color-controller-argument-value-resolver); }
+
+.timeline-graph {
+ margin: 1em 0;
+ width: 100%;
+ background-color: var(--table-background);
+ border: 1px solid var(--table-border);
+}
+
+/* Typography */
+
+.timeline-graph .timeline-label {
+ font-family: var(--font-sans-serif);
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: normal;
+ color: var(--color-text);
+}
+
+.timeline-graph .timeline-label .timeline-sublabel {
+ margin-left: 1em;
+ fill: var(--color-muted);
+}
+
+.timeline-graph .timeline-subrequest,
+.timeline-graph .timeline-border {
+ fill: none;
+ stroke: var(--table-border);
+ stroke-width: 1px;
+}
+
+.timeline-graph .timeline-subrequest {
+ fill: url(#subrequest);
+ fill-opacity: 0.5;
+}
+
+.timeline-subrequest-pattern {
+ fill: var(--table-border);
+}
+
+/* Timeline periods */
+
+.timeline-graph .timeline-period {
+ stroke-width: 0;
+}
+.timeline-graph .{{ classnames.default|raw }} .timeline-period {
+ fill: var(--color-default);
+}
+.timeline-graph .{{ classnames.section|raw }} .timeline-period {
+ fill: var(--color-section);
+}
+.timeline-graph .{{ classnames.event_listener|raw }} .timeline-period {
+ fill: var(--color-event-listener);
+}
+.timeline-graph .{{ classnames.template|raw }} .timeline-period {
+ fill: var(--color-template);
+}
+.timeline-graph .{{ classnames.doctrine|raw }} .timeline-period {
+ fill: var(--color-doctrine);
+}
+.timeline-graph .{{ classnames['messenger.middleware']|raw }} .timeline-period {
+ fill: var(--color-messenger-middleware);
+}
+.timeline-graph .{{ classnames['controller.argument_value_resolver']|raw }} .timeline-period {
+ fill: var(--color-controller-argument-value-resolver);
+}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig
index 57504af1f593d..56eff4c5627b4 100644
--- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.html.twig
@@ -2,22 +2,18 @@
{% import _self as helper %}
-{% if colors is not defined %}
- {% set colors = {
- 'default': '#777',
- 'section': '#999',
- 'event_listener': '#00B8F5',
- 'template': '#66CC00',
- 'doctrine': '#FF6633',
- 'messenger.middleware': '#BDB81E',
- 'controller.argument_value_resolver': '#8c5de6',
- } %}
-{% endif %}
-
+{% set classnames = {
+ 'default': 'timeline-category-default',
+ 'section': 'timeline-category-section',
+ 'event_listener': 'timeline-category-event-listener',
+ 'template': 'timeline-category-template',
+ 'doctrine': 'timeline-category-doctrine',
+ 'messenger.middleware': 'timeline-category-messenger-middleware',
+ 'controller.argument_value_resolver': 'timeline-category-controller-argument-value-resolver',
+} %}
{% block toolbar %}
{% set has_time_events = collector.events|length > 0 %}
-
{% set total_time = has_time_events ? '%.0f'|format(collector.duration) : 'n/a' %}
{% set initialization_time = collector.events|length ? '%.0f'|format(collector.inittime) : 'n/a' %}
{% set status_color = has_time_events and collector.duration > 1000 ? 'yellow' : '' %}
@@ -110,7 +106,7 @@
@@ -128,7 +124,7 @@
{% endif %}
- {{ helper.display_timeline('timeline_' ~ token, colors) }}
+ {{ helper.display_timeline(token, classnames, collector.events, collector.events.__section__.origin) }}
{% if profile.children|length %}
Note: sections with a striped background correspond to sub-requests.
@@ -142,389 +138,34 @@
{{ events.__section__.duration }} ms
- {{ helper.display_timeline('timeline_' ~ child.token, colors) }}
+ {{ helper.display_timeline(child.token, classnames, events, collector.events.__section__.origin) }}
{% endfor %}
{% endif %}
-
+
+
+
{% endblock %}
{% macro dump_request_data(token, events, origin) %}
{% autoescape 'js' %}
{% from _self import dump_events %}
- {
- "id": "{{ token }}",
- "left": {{ "%F"|format(events.__section__.origin - origin) }},
- "events": [
-{{ dump_events(events) }}
- ]
- }
+{
+ id: "{{ token }}",
+ left: {{ "%F"|format(events.__section__.origin - origin) }},
+ end: "{{ '%F'|format(events.__section__.endtime) }}",
+ events: [ {{ dump_events(events) }} ],
+}
{% endautoescape %}
{% endmacro %}
@@ -532,32 +173,45 @@
{% autoescape 'js' %}
{% for name, event in events %}
{% if '__section__' != name %}
- {
- "name": "{{ name }}",
- "category": "{{ event.category }}",
- "origin": {{ "%F"|format(event.origin) }},
- "starttime": {{ "%F"|format(event.starttime) }},
- "endtime": {{ "%F"|format(event.endtime) }},
- "duration": {{ "%F"|format(event.duration) }},
- "memory": {{ "%.1F"|format(event.memory / 1024 / 1024) }},
- "periods": [
- {%- for period in event.periods -%}
- {"start": {{ "%F"|format(period.starttime) }}, "end": {{ "%F"|format(period.endtime) }}}{{ loop.last ? '' : ', ' }}
- {%- endfor -%}
- ]
- }{{ loop.last ? '' : ',' }}
+{
+ name: "{{ name }}",
+ category: "{{ event.category }}",
+ origin: {{ "%F"|format(event.origin) }},
+ starttime: {{ "%F"|format(event.starttime) }},
+ endtime: {{ "%F"|format(event.endtime) }},
+ duration: {{ "%F"|format(event.duration) }},
+ memory: {{ "%.1F"|format(event.memory / 1024 / 1024) }},
+ elements: {},
+ periods: [
+ {%- for period in event.periods -%}
+ {
+ start: {{ "%F"|format(period.starttime) }},
+ end: {{ "%F"|format(period.endtime) }},
+ duration: {{ "%F"|format(period.duration) }},
+ elements: {}
+ },
+ {%- endfor -%}
+ ],
+},
{% endif %}
{% endfor %}
{% endautoescape %}
{% endmacro %}
-{% macro display_timeline(id, colors) %}
+{% macro display_timeline(token, classnames, events, origin) %}
+{% import _self as helper %}
-
- {% for category, color in colors %}
- {{ category }}
- {% endfor %}
-
-
+
+
+
{% endmacro %}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js
new file mode 100644
index 0000000000000..3a057ec604d0d
--- /dev/null
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/time.js
@@ -0,0 +1,412 @@
+'use strict';
+
+class TimelineEngine {
+ /**
+ * @param {Renderer} renderer
+ * @param {Legend} legend
+ * @param {Element} threshold
+ * @param {Object} request
+ * @param {Number} eventHeight
+ * @param {Number} horizontalMargin
+ */
+ constructor(renderer, legend, threshold, request, eventHeight = 36, horizontalMargin = 10) {
+ 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.legend.getClassname(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} Mb`, 'timeline-sublabel');
+
+ label.appendChild(sublabel);
+
+ return label;
+ }
+
+ createPeriod(period, category) {
+ const timeline = this.renderer.createPath(null, 'timeline-period');
+
+ 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, classnames) {
+ this.element = element;
+ this.classnames = classnames;
+
+ this.toggle = this.toggle.bind(this);
+ this.createCategory = this.createCategory.bind(this);
+
+ this.categories = Array.from(Object.keys(classnames)).map(this.createCategory);
+ }
+
+ add(category) {
+ this.get(category).classList.add('present');
+ }
+
+ createCategory(category) {
+ const element = document.createElement('button');
+
+ element.className = `timeline-category ${this.getClassname(category)} active`;
+ element.innerText = category;
+ element.value = category;
+ element.type = 'button';
+ element.addEventListener('click', this.toggle);
+
+ this.element.appendChild(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);
+ }
+
+ getClassname(category) {
+ return this.classnames[category];
+ }
+
+ getSectionClassname() {
+ return this.classnames.section;
+ }
+
+ getDefaultClassname() {
+ return this.classnames.default;
+ }
+
+ getStandardClassenames() {
+ return Array.from(Object.values(this.classnames))
+ .filter(className => className !== this.getSectionClassname());
+ }
+
+ 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) {
+ const element = this.create('path', className);
+
+ if (path) {
+ element.setAttribute('d', path);
+ }
+
+ return element;
+ }
+
+ create(name, className = null) {
+ const element = document.createElementNS(this.ns, name);
+
+ if (className) {
+ element.setAttribute('class', className);
+ }
+
+ return element;
+ }
+}
diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig
index 6fdcc77a0ed75..ac21646a7caf7 100644
--- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig
+++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Profiler/profiler.css.twig
@@ -836,7 +836,7 @@ tr.status-warning td {
font-size: 16px;
padding: 4px;
text-align: right;
- width: 40px;
+ width: 5em;
}
#timeline-control .help {
margin-left: 1em;
@@ -846,15 +846,6 @@ tr.status-warning td {
font-size: 12px;
line-height: 1.5em;
}
-.sf-profiler-timeline .legends span {
- border-left: solid 14px;
- padding: 0 10px 0 5px;
-}
-.sf-profiler-timeline canvas {
- border: 1px solid var(--table-border);
- background: var(--page-background);
- margin: .5em 0;
-}
.sf-profiler-timeline + p.help {
margin-top: 0;
}