| <!DOCTYPE html> |
| <!-- |
| Copyright 2015 The Chromium Authors. All rights reserved. |
| Use of this source code is governed by a BSD-style license that can be |
| found in the LICENSE file. |
| --> |
| |
| <link rel="import" href="/tracing/base/unit.html"> |
| <link rel="import" href="/tracing/ui/base/deep_utils.html"> |
| <link rel="import" href="/tracing/value/histogram.html"> |
| <link rel="import" href="/tracing/value/ui/scalar_context_controller.html"> |
| |
| <script> |
| 'use strict'; |
| tr.exportTo('tr.v.ui', function() { |
| var emojiPrefix = String.fromCharCode(55357); |
| var Emoji = { |
| GRINNING_FACE: emojiPrefix + String.fromCharCode(56835), |
| NEUTRAL_FACE: emojiPrefix + String.fromCharCode(56848), |
| CONFOUNDED_FACE: emojiPrefix + String.fromCharCode(56854) |
| }; |
| |
| /** |
| * @param {undefined|tr.v.ScalarNumeric|tr.v.Histogram} value |
| * @param {Object=} opt_config |
| * @param {!tr.b.Range=} opt_config.customContextRange |
| * @param {boolean=} opt_config.rightAlign |
| * @param {!tr.b.Unit=} opt_config.unit |
| * @param {tr.v.Significance=} opt_config.significance |
| * @param {string=} opt_config.contextGroup |
| * @return {(string|!HTMLElement)} |
| */ |
| function createScalarSpan(value, opt_config) { |
| if (value === undefined) |
| return ''; |
| |
| var config = opt_config || {}; |
| var ownerDocument = config.ownerDocument || document; |
| |
| var span = ownerDocument.createElement('tr-v-ui-scalar-span'); |
| |
| var numericValue; |
| if (value instanceof tr.v.ScalarNumeric) { |
| span.value = value; |
| numericValue = value.value; |
| } else if (value instanceof tr.v.Histogram) { |
| numericValue = value.average; |
| if (numericValue === undefined) |
| return ''; |
| span.setValueAndUnit(numericValue, value.unit); |
| } else { |
| var unit = config.unit; |
| if (unit === undefined) { |
| throw new Error( |
| 'Unit must be provided in config when value is a number'); |
| } |
| span.setValueAndUnit(value, unit); |
| numericValue = value; |
| } |
| |
| if (config.context) |
| span.context = config.context; |
| |
| if (config.customContextRange) |
| span.customContextRange = config.customContextRange; |
| |
| if (config.rightAlign) |
| span.rightAlign = true; |
| |
| if (config.significance !== undefined) |
| span.significance = config.significance; |
| |
| if (config.contextGroup !== undefined) |
| span.contextGroup = config.contextGroup; |
| |
| return span; |
| } |
| |
| return { |
| Emoji: Emoji, |
| createScalarSpan: createScalarSpan |
| }; |
| }); |
| </script> |
| |
| <dom-module id='tr-v-ui-scalar-span'> |
| <template> |
| <style> |
| :host { |
| display: block; |
| position: relative; |
| /* Limit the sparkline's negative z-index to the span only. */ |
| isolation: isolate; |
| } |
| #content.right-align { |
| text-align: right; |
| position: relative; |
| display: block; |
| } |
| #significance.better, #content.better { |
| color: green; |
| } |
| #significance.worse, #content.worse { |
| color: red; |
| } |
| #sparkline { |
| width: 0%; |
| position: absolute; |
| bottom: 0; |
| display: none; |
| height: 100%; |
| background-color: hsla(216, 100%, 94.5%, .75); |
| border-color: hsl(216, 100%, 89%); |
| box-sizing: border-box; |
| z-index: -1; |
| } |
| #sparkline.positive { |
| border-right-style: solid; |
| /* The border width must be kept in sync with buildSparklineStyle_(). */ |
| border-right-width: 1px; |
| } |
| #sparkline:not(.positive) { |
| border-left-style: solid; |
| /* The border width must be kept in sync with buildSparklineStyle_(). */ |
| border-left-width: 1px; |
| } |
| #sparkline.better { |
| background-color: hsla(115, 100%, 93%, .75); |
| border-color: hsl(118, 60%, 80%); |
| } |
| #sparkline.worse { |
| background-color: hsla(0, 100%, 88%, .75); |
| border-color: hsl(0, 100%, 80%); |
| } |
| #warning { |
| margin-left: 4px; |
| font-size: 66%; |
| } |
| #significance { |
| font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", Times, Symbola, Aegyptus, Code2000, Code2001, Code2002, Musica, serif, LastResort; |
| font-size: 13pt; |
| } |
| </style> |
| <span id="sparkline"></span> |
| <span id="content"></span> |
| <span id="significance"></span> |
| <span id="warning" style="display:none">⚠</span> |
| </template> |
| </dom-module> |
| <script> |
| 'use strict'; |
| |
| Polymer({ |
| is: 'tr-v-ui-scalar-span', |
| |
| properties: { |
| /** |
| * String identifier for grouping scalar spans with common context (e.g. |
| * all scalar spans in a single table column would typically share a common |
| * context and, thus, have the same context group identifier). If falsy, |
| * the scalar span will NOT be associated with any context. |
| */ |
| contextGroup: { |
| type: String, |
| reflectToAttribute: true, |
| observer: 'contextGroupChanged_' |
| } |
| }, |
| |
| created: function() { |
| this.value_ = undefined; |
| this.unit_ = undefined; |
| |
| // TODO(petrcermak): Merge this into the context controller. |
| this.context_ = undefined; |
| |
| this.warning_ = undefined; |
| this.significance_ = tr.v.Significance.DONT_CARE; |
| |
| // To avoid unnecessary DOM traversal, search for the context controller |
| // only when necessary (when the span is attached and has a context group). |
| this.shouldSearchForContextController_ = false; |
| this.lazyContextController_ = undefined; |
| this.onContextUpdated_ = this.onContextUpdated_.bind(this); |
| |
| // The span can specify a custom context range, which will override the |
| // values from the context controller. |
| this.customContextRange_ = undefined; |
| }, |
| |
| get significance() { |
| return this.significance_; |
| }, |
| |
| set significance(s) { |
| this.significance_ = s; |
| this.updateContents_(); |
| }, |
| |
| set contentTextDecoration(deco) { |
| this.$.content.style.textDecoration = deco; |
| }, |
| |
| get value() { |
| return this.value_; |
| }, |
| |
| set value(value) { |
| if (value instanceof tr.v.ScalarNumeric) { |
| this.value_ = value.value; |
| this.unit_ = value.unit; |
| } else { |
| this.value_ = value; |
| } |
| this.updateContents_(); |
| if (this.hasContext_(this.contextGroup)) |
| this.contextController_.onScalarSpanUpdated(this.contextGroup, this); |
| else |
| this.updateSparkline_(); |
| }, |
| |
| get contextController_() { |
| if (this.shouldSearchForContextController_) { |
| this.lazyContextController_ = |
| tr.v.ui.getScalarContextControllerForElement(this); |
| this.shouldSearchForContextController_ = false; |
| } |
| return this.lazyContextController_; |
| }, |
| |
| hasContext_: function(contextGroup) { |
| // The ordering here is important. It ensures that we avoid a DOM traversal |
| // when the span doesn't have a context group. |
| return !!(contextGroup && this.contextController_); |
| }, |
| |
| contextGroupChanged_: function(newContextGroup, oldContextGroup) { |
| this.detachFromContextControllerIfPossible_(oldContextGroup); |
| if (!this.attachToContextControllerIfPossible_(newContextGroup)) { |
| // If the span failed to attach to a controller, it won't receive a |
| // context-updated event, so we trigger it manually. |
| this.onContextUpdated_(); |
| } |
| }, |
| |
| attachToContextControllerIfPossible_: function(contextGroup) { |
| if (!this.hasContext_(contextGroup)) |
| return false; |
| this.contextController_.addEventListener( |
| 'context-updated', this.onContextUpdated_); |
| this.contextController_.onScalarSpanAdded(contextGroup, this); |
| return true; |
| }, |
| |
| detachFromContextControllerIfPossible_: function(contextGroup) { |
| if (!this.hasContext_(contextGroup)) |
| return; |
| this.contextController_.removeEventListener( |
| 'context-updated', this.onContextUpdated_); |
| this.contextController_.onScalarSpanRemoved(contextGroup, this); |
| }, |
| |
| attached: function() { |
| tr.b.Unit.addEventListener( |
| 'display-mode-changed', this.updateContents_.bind(this)); |
| this.shouldSearchForContextController_ = true; |
| this.attachToContextControllerIfPossible_(this.contextGroup); |
| }, |
| |
| detached: function() { |
| tr.b.Unit.removeEventListener( |
| 'display-mode-changed', this.updateContents_.bind(this)); |
| this.detachFromContextControllerIfPossible_(this.contextGroup); |
| this.shouldSearchForContextController_ = false; |
| this.lazyContextController_ = undefined; |
| }, |
| |
| onContextUpdated_: function() { |
| this.updateSparkline_(); |
| }, |
| |
| get context() { |
| return this.context_; |
| }, |
| |
| set context(context) { |
| this.context_ = context; |
| this.updateContents_(); |
| }, |
| |
| get unit() { |
| return this.unit_; |
| }, |
| |
| set unit(unit) { |
| this.unit_ = unit; |
| this.updateContents_(); |
| this.updateSparkline_(); |
| }, |
| |
| setValueAndUnit: function(value, unit) { |
| this.value_ = value; |
| this.unit_ = unit; |
| this.updateContents_(); |
| }, |
| |
| get customContextRange() { |
| return this.customContextRange_; |
| }, |
| |
| set customContextRange(customContextRange) { |
| this.customContextRange_ = customContextRange; |
| this.updateSparkline_(); |
| }, |
| |
| get rightAlign() { |
| return Polymer.dom(this.$.content).classList.contains('right-align'); |
| }, |
| |
| set rightAlign(rightAlign) { |
| if (rightAlign) |
| Polymer.dom(this.$.content).classList.add('right-align'); |
| else |
| Polymer.dom(this.$.content).classList.remove('right-align'); |
| }, |
| |
| updateSparkline_: function() { |
| Polymer.dom(this.$.sparkline).classList.remove('positive'); |
| Polymer.dom(this.$.sparkline).classList.remove('better'); |
| Polymer.dom(this.$.sparkline).classList.remove('worse'); |
| Polymer.dom(this.$.sparkline).classList.remove('same'); |
| this.$.sparkline.style.display = 'none'; |
| this.$.sparkline.style.left = '0'; |
| this.$.sparkline.style.width = '0'; |
| |
| // Custom context range takes precedence over controller context range. |
| var range = this.customContextRange_; |
| if (!range && this.hasContext_(this.contextGroup)) { |
| var context = this.contextController_.getContext(this.contextGroup); |
| if (context) |
| range = context.range; |
| } |
| if (!range || range.isEmpty) |
| return; |
| |
| var leftPoint = Math.min(range.min, 0); |
| var rightPoint = Math.max(range.max, 0); |
| var pointDistance = rightPoint - leftPoint; |
| if (pointDistance === 0) { |
| // This can happen, for example, when all spans within the context have |
| // zero values (so |range| is [0, 0]). |
| return; |
| } |
| |
| // Draw the sparkline. |
| this.$.sparkline.style.display = 'block'; |
| var left, width; |
| if (this.value > 0) { |
| width = Math.min(this.value, rightPoint); |
| left = -leftPoint; |
| Polymer.dom(this.$.sparkline).classList.add('positive'); |
| } else if (this.value <= 0) { |
| width = -Math.max(this.value, leftPoint); |
| left = (-leftPoint) - width; |
| } |
| this.$.sparkline.style.left = this.buildSparklineStyle_( |
| left / pointDistance, false); |
| this.$.sparkline.style.width = this.buildSparklineStyle_( |
| width / pointDistance, true); |
| |
| // Set the sparkline color (if applicable). |
| var changeClass = this.changeClassName_; |
| if (changeClass) |
| Polymer.dom(this.$.sparkline).classList.add(changeClass); |
| }, |
| |
| buildSparklineStyle_: function(ratio, isWidth) { |
| // To avoid visual glitches around the zero value bar, we subtract 1 pixel |
| // from the width of the element and multiply the remainder (100% - 1px) by |
| // |ratio|. The extra pixel is used for the sparkline border. This allows |
| // us to align zero sparklines with both positive and negative values: |
| // |
| // ::::::::::| +10 MiB |
| // :::::| +5 MiB |
| // | 0 MiB |
| // |::::: -5 MiB |
| // |:::::::::: -10 MiB |
| // |
| var position = 'calc(' + ratio + ' * (100% - 1px)'; |
| if (isWidth) |
| position += ' + 1px'; // Extra pixel for sparkline border. |
| position += ')'; |
| return position; |
| }, |
| |
| updateContents_: function() { |
| Polymer.dom(this.$.significance).textContent = ''; |
| Polymer.dom(this.$.content).textContent = ''; |
| Polymer.dom(this.$.content).classList.remove('better'); |
| Polymer.dom(this.$.content).classList.remove('worse'); |
| Polymer.dom(this.$.content).classList.remove('same'); |
| Polymer.dom(this.$.significance).classList.remove('better'); |
| Polymer.dom(this.$.significance).classList.remove('worse'); |
| Polymer.dom(this.$.significance).classList.remove('same'); |
| |
| if (this.unit_ === undefined) |
| return; |
| |
| this.$.content.title = ''; |
| Polymer.dom(this.$.content).textContent = |
| this.unit_.format(this.value, this.context); |
| this.updateDelta_(); |
| }, |
| |
| updateDelta_: function() { |
| if (!this.unit_.isDelta) { |
| this.$.significance.style.display = 'none'; |
| return; |
| } |
| |
| this.$.significance.style.display = ''; |
| |
| var changeClass = this.changeClassName_; |
| if (!changeClass) |
| return; // Not a delta or we don't care. |
| |
| var emoji, title; |
| switch (changeClass) { |
| case 'better': |
| emoji = tr.v.ui.Emoji.GRINNING_FACE; |
| title = 'improvement'; |
| break; |
| case 'worse': |
| emoji = tr.v.ui.Emoji.CONFOUNDED_FACE; |
| title = 'regression'; |
| break; |
| case 'same': |
| emoji = tr.v.ui.Emoji.NEUTRAL_FACE; |
| title = 'no change'; |
| break; |
| default: |
| throw new Error('Unknown change class: ' + changeClass); |
| } |
| |
| // Set the content class separately from the significance class so that |
| // NEUTRAL_FACE is always a neutral color. |
| Polymer.dom(this.$.content).classList.add(changeClass); |
| |
| switch (this.significance) { |
| case tr.v.Significance.DONT_CARE: |
| emoji = ''; |
| changeClass = 'same'; |
| break; |
| |
| case tr.v.Significance.INSIGNIFICANT: |
| if (changeClass !== 'same') |
| title = 'insignificant ' + title; |
| emoji = tr.v.ui.Emoji.NEUTRAL_FACE; |
| changeClass = 'same'; |
| break; |
| |
| case tr.v.Significance.SIGNIFICANT: |
| if (changeClass === 'same') |
| throw new Error('How can no change be significant?'); |
| title = 'significant ' + title; |
| break; |
| } |
| |
| Polymer.dom(this.$.significance).textContent = emoji; |
| this.$.significance.title = title; |
| this.$.content.title = title; |
| Polymer.dom(this.$.significance).classList.add(changeClass); |
| }, |
| |
| get changeClassName_() { |
| if (!this.unit_ || !this.unit_.isDelta) |
| return undefined; |
| |
| switch (this.unit_.improvementDirection) { |
| case tr.b.ImprovementDirection.DONT_CARE: |
| return undefined; |
| case tr.b.ImprovementDirection.BIGGER_IS_BETTER: |
| if (this.value === 0) |
| return 'same'; |
| return this.value > 0 ? 'better' : 'worse'; |
| case tr.b.ImprovementDirection.SMALLER_IS_BETTER: |
| if (this.value === 0) |
| return 'same'; |
| return this.value < 0 ? 'better' : 'worse'; |
| default: |
| throw new Error('Unknown improvement direction: ' + |
| this.unit_.improvementDirection); |
| } |
| }, |
| |
| get warning() { |
| return this.warning_; |
| }, |
| |
| set warning(warning) { |
| this.warning_ = warning; |
| var warningEl = this.$.warning; |
| if (this.warning_) { |
| warningEl.title = warning; |
| warningEl.style.display = ''; |
| } else { |
| warningEl.title = ''; |
| warningEl.style.display = 'none'; |
| } |
| }, |
| |
| // tr-v-ui-time-stamp-span property |
| get timestamp() { |
| return this.value; |
| }, |
| |
| set timestamp(timestamp) { |
| if (timestamp instanceof tr.b.u.TimeStamp) { |
| this.value = timestamp; |
| return; |
| } |
| this.setValueAndUnit(timestamp, tr.b.u.Units.timeStampInMs); |
| }, |
| |
| // tr-v-ui-time-duration-span property |
| get duration() { |
| return this.value; |
| }, |
| |
| set duration(duration) { |
| if (duration instanceof tr.b.u.TimeDuration) { |
| this.value = duration; |
| return; |
| } |
| this.setValueAndUnit(duration, tr.b.u.Units.timeDurationInMs); |
| } |
| }); |
| </script> |