feature #30450 [Profiler] Render the performance graph with SVG (Tom32i)

This PR was squashed before being merged into the 4.3-dev branch (closes #30450).

Discussion
----------

[Profiler] Render the performance graph with SVG

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | Part 1, 3 and 4 of #27262
| License       | MIT
| Doc PR        | n/a

Following a suggestion by @ogizanagi in #27262,
Here's a proposal to render the Request Graph, from the performance profiler panel, with SVG instead of canvas.

Some benefits of the SVG format:
- The text labels are searchable and can be selected.
- It renders well on high DPI monitors.
- [Colors and text styles](https://github.com/symfony/symfony/issues/27262#issuecomment-388868068) can be defined with CSS just like the rest of the page.

In addition, SVG allow us to consider (and easily implement) interactives features such as:
- Zoom in and time navigation (thanks to the viewport).
- Highlight hovered line (or other DOM related events).

Preview:

![screenshot_2019-03-08 symfony profiler 1](https://user-images.githubusercontent.com/1846873/54036727-a33f4300-41bc-11e9-8be7-b1de10d4afd9.png)

Filtered events example:

![capture d ecran 2019-03-08 a 17 22 47](https://user-images.githubusercontent.com/1846873/54041039-00d88d00-41c7-11e9-9590-23e809415c34.png)

### Progress :
- [x] Render request events in SVG
- [x] Show labels with duration and memory
- [x] Show specific markers at start / end of lines
- [x] Re-render graph when window resize
- [x] Re-render graph when threshold change.
- [x]  Generate graph legend with only existing categories (part 1. of #27262 )
- [x] Show sub-request area with hatched pattern
- [x]  Allow to hide categories by clicking them on the legend (part 3. of #27262 )
- [x] Handle text overflow on long labels.
- [x] Ensure JS code is compatible with all supported browsers (used [classes](https://caniuse.com/#feat=es6-class) and [arrow functions](https://caniuse.com/#feat=arrow-functions).
- ~Add left-padding to sub-request graph?~

Commits
-------

a69a718ec9 [Profiler] Render the performance graph with SVG
This commit is contained in:
Fabien Potencier 2019-03-17 08:47:26 +01:00
commit 0762d2d9ac
5 changed files with 588 additions and 421 deletions

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
4.3.0
-----
* Replaced the canvas performance graph renderer with an SVG renderer
4.1.0
-----

View File

@ -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);
}

View File

@ -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 @@
<form id="timeline-control" action="" method="get">
<input type="hidden" name="panel" value="time">
<label for="threshold">Threshold</label>
<input type="number" size="3" name="threshold" id="threshold" value="3" min="0"> ms
<input type="number" name="threshold" id="threshold" value="1" min="0" placeholder="1.1"> ms
<span class="help">(timeline only displays events with a duration longer than this threshold)</span>
</form>
@ -128,7 +124,7 @@
</h3>
{% endif %}
{{ helper.display_timeline('timeline_' ~ token, colors) }}
{{ helper.display_timeline(token, classnames, collector.events, collector.events.__section__.origin) }}
{% if profile.children|length %}
<p class="help">Note: sections with a striped background correspond to sub-requests.</p>
@ -142,389 +138,34 @@
<small>{{ events.__section__.duration }} ms</small>
</h4>
{{ helper.display_timeline('timeline_' ~ child.token, colors) }}
{{ helper.display_timeline(child.token, classnames, events, collector.events.__section__.origin) }}
{% endfor %}
{% endif %}
<script>{% autoescape 'js' %}//<![CDATA[
/**
* In-memory key-value cache manager
*/
var cache = new function() {
"use strict";
var dict = {};
this.get = function(key) {
return dict.hasOwnProperty(key)
? dict[key]
: null;
};
this.set = function(key, value) {
dict[key] = value;
return value;
};
};
/**
* Query an element with a CSS selector.
*
* @param {string} selector - a CSS-selector-compatible query string
*
* @return DOMElement|null
*/
function query(selector)
{
"use strict";
var key = 'SELECTOR: ' + selector;
return cache.get(key) || cache.set(key, document.querySelector(selector));
}
/**
* Canvas Manager
*/
function CanvasManager(requests, maxRequestTime) {
"use strict";
var _drawingColors = {{ colors|json_encode|raw }},
_storagePrefix = 'timeline/',
_threshold = 1,
_requests = requests,
_maxRequestTime = maxRequestTime;
/**
* Check whether this event is a child event.
*
* @return true if it is
*/
function isChildEvent(event)
{
return '__section__.child' === event.name;
}
/**
* Check whether this event is categorized in 'section'.
*
* @return true if it is
*/
function isSectionEvent(event)
{
return 'section' === event.category;
}
/**
* Get the width of the container.
*/
function getContainerWidth()
{
return query('#collector-content h2').clientWidth;
}
function getCssVarValue(varName)
{
return getComputedStyle(document.querySelector('body')).getPropertyValue(varName);
}
/**
* Draw one canvas.
*
* @param request the request object
* @param max <subjected for removal>
* @param threshold the threshold (lower bound) of the length of the timeline (in milliseconds)
* @param width the width of the canvas
*/
this.drawOne = function(request, max, threshold, width)
{
"use strict";
var text,
ms,
xc,
drawableEvents,
mainEvents,
elementId = 'timeline_' + request.id,
canvasHeight = 0,
gapPerEvent = 38,
colors = _drawingColors,
space = 10.5,
ratio = (width - space * 2) / max,
h = space,
x = request.left * ratio + space, // position
canvas = cache.get(elementId) || cache.set(elementId, document.getElementById(elementId)),
ctx = canvas.getContext("2d"),
scaleRatio,
devicePixelRatio;
// Filter events whose total time is below the threshold.
drawableEvents = request.events.filter(function(event) {
return event.duration >= threshold;
});
canvasHeight += gapPerEvent * drawableEvents.length;
// For retina displays so text and boxes will be crisp
devicePixelRatio = window.devicePixelRatio == "undefined" ? 1 : window.devicePixelRatio;
scaleRatio = devicePixelRatio / 1;
canvas.width = width * scaleRatio;
canvas.height = canvasHeight * scaleRatio;
canvas.style.width = width + 'px';
canvas.style.height = canvasHeight + 'px';
ctx.scale(scaleRatio, scaleRatio);
ctx.textBaseline = "middle";
ctx.lineWidth = 0;
// For each event, draw a line.
ctx.strokeStyle = getCssVarValue('--table-border');
// set the background color of the canvas
ctx.fillStyle = getCssVarValue('--table-background');
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawableEvents.forEach(function(event) {
event.periods.forEach(function(period) {
var timelineHeadPosition = x + period.start * ratio;
if (isChildEvent(event)) {
/* create a striped background dynamically */
var img = new Image();
img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKBAMAAAB/HNKOAAAAIVBMVEX////w8PDd7h7d7h7d7h7d7h7w8PDw8PDw8PDw8PDw8PAOi84XAAAAKUlEQVQImWNI71zAwMBQMYuBgY0BxExnADErGEDMTgYQE8hnAKtCZwIAlcMNSR9a1OEAAAAASUVORK5CYII=';
var pattern = ctx.createPattern(img, 'repeat');
ctx.fillStyle = pattern;
ctx.fillRect(timelineHeadPosition, 0, (period.end - period.start) * ratio, canvasHeight);
} else if (isSectionEvent(event)) {
var timelineTailPosition = x + period.end * ratio;
ctx.beginPath();
ctx.moveTo(timelineHeadPosition, 0);
ctx.lineTo(timelineHeadPosition, canvasHeight);
ctx.moveTo(timelineTailPosition, 0);
ctx.lineTo(timelineTailPosition, canvasHeight);
ctx.fill();
ctx.closePath();
ctx.stroke();
}
});
});
// Filter for main events.
mainEvents = drawableEvents.filter(function(event) {
return !isChildEvent(event)
});
// For each main event, draw the visual presentation of timelines.
mainEvents.forEach(function(event) {
h += 8;
// For each sub event, ...
event.periods.forEach(function(period) {
// Set the drawing style.
ctx.fillStyle = colors['default'];
ctx.strokeStyle = colors['default'];
if (colors[event.name]) {
ctx.fillStyle = colors[event.name];
ctx.strokeStyle = colors[event.name];
} else if (colors[event.category]) {
ctx.fillStyle = colors[event.category];
ctx.strokeStyle = colors[event.category];
}
// Draw the timeline
var timelineHeadPosition = x + period.start * ratio;
if (!isSectionEvent(event)) {
ctx.fillRect(timelineHeadPosition, h + 3, 2, 8);
ctx.fillRect(timelineHeadPosition, h, (period.end - period.start) * ratio || 2, 6);
} else {
var timelineTailPosition = x + period.end * ratio;
ctx.beginPath();
ctx.moveTo(timelineHeadPosition, h);
ctx.lineTo(timelineHeadPosition, h + 11);
ctx.lineTo(timelineHeadPosition + 8, h);
ctx.lineTo(timelineHeadPosition, h);
ctx.fill();
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(timelineTailPosition, h);
ctx.lineTo(timelineTailPosition, h + 11);
ctx.lineTo(timelineTailPosition - 8, h);
ctx.lineTo(timelineTailPosition, h);
ctx.fill();
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(timelineHeadPosition, h);
ctx.lineTo(timelineTailPosition, h);
ctx.lineTo(timelineTailPosition, h + 2);
ctx.lineTo(timelineHeadPosition, h + 2);
ctx.lineTo(timelineHeadPosition, h);
ctx.fill();
ctx.closePath();
ctx.stroke();
}
});
h += 30;
ctx.beginPath();
ctx.strokeStyle = "#E0E0E0";
ctx.moveTo(0, h - 10);
ctx.lineTo(width, h - 10);
ctx.closePath();
ctx.stroke();
});
h = space;
// For each event, draw the label.
mainEvents.forEach(function(event) {
ctx.fillStyle = getCssVarValue('--color-text');
ctx.font = "12px sans-serif";
text = event.name;
ms = " " + (event.duration < 1 ? event.duration : parseInt(event.duration, 10)) + " ms / " + event.memory + " MB";
if (x + event.starttime * ratio + ctx.measureText(text + ms).width > width) {
ctx.textAlign = "end";
ctx.font = "10px sans-serif";
ctx.fillStyle = getCssVarValue('--color-muted');
xc = x + event.endtime * ratio - 1;
ctx.fillText(ms, xc, h);
xc -= ctx.measureText(ms).width;
ctx.font = "12px sans-serif";
ctx.fillStyle = getCssVarValue('--color-text');
ctx.fillText(text, xc, h);
} else {
ctx.textAlign = "start";
ctx.font = "13px sans-serif";
ctx.fillStyle = getCssVarValue('--color-text');
xc = x + event.starttime * ratio + 1;
ctx.fillText(text, xc, h);
xc += ctx.measureText(text).width;
ctx.font = "11px sans-serif";
ctx.fillStyle = getCssVarValue('--color-muted');
ctx.fillText(ms, xc, h);
}
h += gapPerEvent;
});
};
this.drawAll = function(width, threshold)
{
"use strict";
width = width || getContainerWidth();
threshold = threshold || this.getThreshold();
var self = this;
_requests.forEach(function(request) {
self.drawOne(request, _maxRequestTime, threshold, width);
});
};
this.getThreshold = function() {
var threshold = Sfjs.getPreference(_storagePrefix + 'threshold');
if (null === threshold) {
return _threshold;
}
_threshold = parseInt(threshold);
return _threshold;
};
this.setThreshold = function(threshold)
{
_threshold = threshold;
Sfjs.setPreference(_storagePrefix + 'threshold', threshold);
return this;
};
}
function canvasAutoUpdateOnResizeAndSubmit(e) {
e.preventDefault();
canvasManager.drawAll();
}
function canvasAutoUpdateOnThresholdChange(e) {
canvasManager
.setThreshold(query('input[name="threshold"]').value)
.drawAll();
}
var requests_data = {
"max": {{ "%F"|format(collector.events.__section__.endtime) }},
"requests": [
{{ helper.dump_request_data(token, collector.events, collector.events.__section__.origin) }}
{% if profile.children|length %}
,
{% for child in profile.children %}
{{ helper.dump_request_data(child.token, child.getcollector('time').events, collector.events.__section__.origin) }}{{ loop.last ? '' : ',' }}
{% endfor %}
{% endif %}
]
};
var canvasManager = new CanvasManager(requests_data.requests, requests_data.max);
query('input[name="threshold"]').value = canvasManager.getThreshold();
canvasManager.drawAll();
// Update the colors of legends.
var timelineLegends = document.querySelectorAll('.sf-profiler-timeline > .legends > span[data-color]');
for (var i = 0; i < timelineLegends.length; ++i) {
var timelineLegend = timelineLegends[i];
timelineLegend.style.borderLeftColor = timelineLegend.getAttribute('data-color');
}
// Bind event handlers
var elementTimelineControl = query('#timeline-control'),
elementThresholdControl = query('input[name="threshold"]');
window.onresize = canvasAutoUpdateOnResizeAndSubmit;
elementTimelineControl.onsubmit = canvasAutoUpdateOnResizeAndSubmit;
elementThresholdControl.onclick = canvasAutoUpdateOnThresholdChange;
elementThresholdControl.onchange = canvasAutoUpdateOnThresholdChange;
elementThresholdControl.onkeyup = canvasAutoUpdateOnThresholdChange;
window.setTimeout(function() {
canvasAutoUpdateOnThresholdChange(null);
}, 50);
//]]>{% endautoescape %}</script>
<svg id="timeline-template" width="0" height="0">
<defs>
<pattern id="subrequest" class="timeline-subrequest-pattern" patternUnits="userSpaceOnUse" width="20" height="20" viewBox="0 0 40 40">
<path d="M0 40L40 0H20L0 20M40 40V20L20 40"/>
</pattern>
</defs>
</svg>
<style type="text/css">
{% include '@WebProfiler/Collector/time.css.twig' with classnames %}
</style>
<script>
{% include '@WebProfiler/Collector/time.js' %}
</script>
{% 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 %}
<div class="sf-profiler-timeline">
<div class="legends">
{% for category, color in colors %}
<span data-color="{{ color }}">{{ category }}</span>
{% endfor %}
</div>
<canvas width="680" height="" id="{{ id }}" class="timeline"></canvas>
<div id="legend-{{ token }}" class="legends"></div>
<svg id="timeline-{{ token }}" class="timeline-graph"></svg>
<script>{% autoescape 'js' %}
window.addEventListener('load', function onLoad() {
new TimelineEngine(
new SvgRenderer(document.getElementById('timeline-{{ token }}')),
new Legend(document.getElementById('legend-{{ token }}'), {{ classnames|json_encode|raw }}),
document.getElementById('threshold'),
{{ helper.dump_request_data(token, events, origin) }}
);
});
{% endautoescape %}</script>
</div>
{% endmacro %}

View File

@ -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;
}
}

View File

@ -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;
}