blob: 9c52cb3c31068ae50237fd02f06b37c76fc591a9 [file] [log] [blame]
/**
* @module modules/plot-simple-sk
* @description <h2><code>plot-simple-sk</code></h2>
*
* A custom element for plotting x,y graphs.
*
* @evt trace_selected - Event produced when the user clicks on a line.
* The e.detail contains the id of the line and the index of the
* point in the line closest to the mouse, and the [x, y] value
* of the point in 'pt'.
*
* <pre>
* e.detail = {
* id: 'id of trace',
* index: 3,
* pt: [2, 34.5],
* }
* </pre>
*
* @evt trace_focused - Event produced when the user moves the mouse close
* to a line. The e.detail contains the id of the line and the index of the
* point in the line closest to the mouse.
* <pre>
* e.detail = {
* id: 'id of trace',
* index: 3,
* pt: [2, 34.5],
* }
* </pre>
*
* @evt zoom - Event produced when the user has zoomed into a region
* by dragging.
*
* @attr width - The width of the element in px.
* @attr height - The height of the element in px.
*
*/
import { define } from 'elements-sk/define'
import { html, render } from 'lit-html'
import { ElementSk } from '../../../infra-sk/modules/ElementSk'
import { Chart } from 'chart.js'
import 'chartjs-plugin-annotation'
import 'chartjs-plugin-zoom'
/**
* @constant {string} - Prefix for trace ids that are not real traces.
*/
const SPECIAL = 'special';
/**
* @constant {Array} - Colors used for traces.
*/
const colors = [
'#000000',
'#1B9E77',
'#D95F02',
'#7570B3',
'#E7298A',
'#66A61E',
'#E6AB02',
'#A6761D',
'#666666',
];
const template = (ele) => html`
<canvas width=${ele.width} height=${ele.height}></canvas>
`;
define('plot-simple-sk', class extends ElementSk {
constructor() {
super(template);
}
connectedCallback() {
super.connectedCallback();
// Only create the _chart once.
if (!this._chart) {
this._render();
// The location of the XBar. See setXBar().
this._xbarx = 0;
// The locations of the background bands. See setBanding().
this._bands = [];
this._chart = new Chart(this.querySelector('canvas'), {
type: 'line',
data: {
datasets: [],
},
options: {
responsive: true,
maintainAspectRatio: false,
spanGaps: true,
animation: {
duration: 0, // general animation time
},
hover: {
animationDuration: 0, // duration of animations when hovering an item
},
annotation: {
annotations: [],
},
responsiveAnimationDuration: 0, // animation duration after a resize
elements: {
line: {
tension: 0 // disables bezier curves
}
},
tooltips: {
intersect: false,
mode: 'nearest',
animationDuration: 0,
caretPadding: 10,
callbacks: {
label: (tooltipItem, data) => {
const label = data.datasets[tooltipItem.datasetIndex].label || '';
const tooltipValue = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];
let detail = {
id: label,
value: tooltipValue,
index: tooltipItem.index,
pt: [tooltipItem.index, tooltipItem.value],
};
this.dispatchEvent(new CustomEvent('trace_focused', {detail: detail, bubbles: true}));
return parseFloat(tooltipValue).toLocaleString();
}
},
},
scales: {
xAxes: [{
type: 'time',
position: 'bottom',
time: {
source: 'labels',
displayFormats: {
'millisecond': 'h:mm:ss.SSS A',
'second': 'h:mm:ss A',
'minute': 'h:mm A',
'hour': 'ddd h A',
'day': 'ddd h A',
'week': 'D MMM',
'month': 'D MMM',
},
},
distribution: 'series',
ticks: {
autoSkip: true,
autoSkipPadding: 10,
source: 'data',
minRotation: 60,
autoSkip: true,
maxTicksLimit: 10,
},
}],
yAxes: [{
ticks: {
callback: function(value, index, values) {
return parseFloat(value).toLocaleString();
},
},
}]
},
legend: {
display: false,
},
onClick: (e) => {
let eles = this._chart.getElementAtEvent(e);
if (!eles.length) {
return
}
let ele = eles[0];
let id = this._chart.data.datasets[ele._datasetIndex].label;
if (id.startsWith(SPECIAL)) {
return
}
let index = ele._index;
let value = this._chart.data.datasets[ele._datasetIndex].data[ele._index];
let detail = {
id: id,
index: index,
value: value,
pt: [index, value],
};
this.dispatchEvent(new CustomEvent('trace_selected', {detail: detail, bubbles: true}));
this.setHighlight([id]);
},
plugins: {
zoom: {
pan: {
enabled: false,
},
zoom: {
enabled: true,
drag: true,
drag: {
borderColor: 'lightgray',
borderWidth: 3,
},
mode: 'xy',
rangeMin: {
x: null,
y: null
},
rangeMax: {
x: null,
y: null
},
// Speed of zoom via mouse wheel
// (percentage of zoom on a wheel event)
speed: 0.1,
onZoom: (c) => {
console.log(c.chart.scales);
let detail = {
xMin: c.chart.scales['x-axis-0'].min,
xMax: c.chart.scales['x-axis-0'].max,
yMin: c.chart.scales['y-axis-0'].min,
yMax: c.chart.scales['y-axis-0'].max,
};
this.dispatchEvent(new CustomEvent('zoom', {detail: detail, bubbles: true}));
},
}
}
}
},
});
}
}
// Convert the different in time between d1 and d2 into the units to
// when displaying ticks. See https://www.chartjs.org/docs/latest/axes/cartesian/time.html#display-formats
// and https://momentjs.com/docs/#/displaying/format/
//
// This works in coordination with the values set in time.displayFormats.
_diffDateToUnits(d1, d2) {
let diff = d2 - d1;
diff = diff / 1000;
if (diff < 1) {
return 'millisecond';
}
diff = diff / 60;
if (diff < 1) {
return 'second';
}
diff = diff / 60;
if (diff < 1) {
return 'minute';
}
diff = diff / 24;
if (diff < 1) {
return 'hour';
}
diff = diff / 7;
if (diff < 1) {
return 'day';
}
diff = diff / 365;
if (diff < 1) {
return 'week';
}
return 'month';
}
/**
* This is a super simple hash (h = h * 31 + x_i) currently used
* for things like assigning colors to graphs based on trace ids. It
* shouldn't be used for anything more serious than that.
*
* @return {Number} A 32 bit hash for the given string.
*/
_hashString(s) {
let hash = 0;
for (let i = s.length - 1; i >= 0; i--) {
hash = ((hash << 5) - hash) + s.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
/**
* Adds lines to be displayed.
*
* Any line id that begins with 'special' will be treated specially,
* i.e. it will be presented as a dashed black line that doesn't
* generate events. This may be useful for adding a line at y=0,
* or a reference trace.
*
* @param {Object} lines - A map from trace id to arrays of [x, y] pairs.
* @param {Array=} labels - An array of Date's that represent the x values of
* data to plot.
*
* TODO(jcgregorio) Switch lines to be a map to an Array of y values
* since that's what chart.js expects.
*
* @example
*
* let lines = {
* foo: [
* [0.1, 3.7],
* [0.2, 3.8],
* [0.4, 3.0],
* ],
* bar: [
* [0.0, 2.5],
* [0.2, 4.2],
* [0.5, 3.9],
* ],
* };
* let labels = [new Date(), new Date()];
* plot.addLines(lines, labels);
*/
addLines(lines, labels) {
if (labels) {
this._chart.data.labels = labels;
let unit = this._diffDateToUnits(labels[0], labels[labels.length-1]);
this._chart.options.scales.xAxes[0].time.unit = unit;
}
let exists = {};
this._chart.data.datasets.forEach((d) => {
exists[d.label] = true;
});
Object.keys(lines).forEach((id) => {
if (exists[id]) {
return;
}
let data = lines[id].map(arr => arr[1]);
this._chart.data.datasets.push({
label: id,
data: data,
fill: false,
borderColor: colors[(this._hashString(id) % 8) + 1],
borderWidth: 1,
});
});
this._chart.update();
}
/**
* Delete a line from being plotted.
*
* @param {string} id - The trace id.
*/
deleteLine(id) {
let ds = this._chart.data.datasets;
for (let i = 0; i < ds.length; i++) {
if (ds[i].label === id) {
this._chart.data.datasets.splice(i, 1);
}
}
this._chart.update();
}
/**
* Remove all lines from plot.
*/
removeAll() {
this._chart.data.datasets = [];
this._chart.update();
}
/**
* Highlight one or more traces.
*
* @param {Array} ids - An array of trace ids.
*/
setHighlight(ids) {
this._chart.data.datasets.forEach((dataset) => {
if (ids.indexOf(dataset.label) != -1) {
dataset.borderWidth = 3;
} else {
dataset.borderWidth = 1;
}
});
this._chart.update();
}
/**
* Returns the trace ids of all highlighted traces.
*
* @return {Array} Trace ids.
*/
highlighted() {
let h = [];
this._chart.data.datasets.forEach((dataset) => {
if (dataset.borderWidth === 3) {
h.push(dataset.label);
}
});
return h;
}
/**
* Clears all highlighting from all traces.
*/
clearHighlight() {
this._chart.data.datasets.forEach((dataset) => {
dataset.borderWidth = 1;
});
this._chart.update();
}
/**
* Turns on a vertical bar at the given position.
*
* @param {Number} x - The offset into the labels where the bar
* should be positioned.
*/
setXBar(x) {
this.clearXBar();
this._chart.options.annotation.annotations.push({
id: 'xbar',
type: 'line',
mode: 'vertical',
scaleID: 'x-axis-0',
value: this._chart.data.labels[x],
borderColor: 'red',
borderWidth: 3,
drawTime: 'beforeDatasetsDraw',
});
this._chart.update();
}
/**
* Removes the x-bar from being displayed.
*
* @param {Number} x - The offset into the labels where the bar
* should be removed from.
*/
clearXBar(x) {
this._chart.options.annotation.annotations =
this._chart.options.annotation.annotations.filter(ann => {
return ann.id != 'xbar';
});
this._chart.update();
}
/**
* Sets the banding over ranges of labels.
*
* @param {Array} bands - A list of [x1, x2] offsets
* into labels.
*
* @example
*
* let bands = [
* [0.0, 0.1],
* [0.5, 1.2],
* ];
* plot.setBanding(bands);
*/
setBanding(bands) {
this._chart.options.annotation.annotations = [];
bands.forEach((band, i) => {
this._chart.options.annotation.annotations.push({
id: `band-${i}`,
type: 'box',
mode: 'vertical',
xScaleID: 'x-axis-0',
xMin: this._chart.data.labels[band[0]],
xMax: this._chart.data.labels[band[1]],
backgroundColor: 'rgba(0, 0, 0, 0.1)',
drawTime: 'beforeDatasetsDraw',
});
});
this._chart.update();
}
/**
* Resets the zoom to default.
*/
resetAxes() {
if (this._chart) {
this._chart.resetZoom();
}
}
static get observedAttributes() {
return ['width', 'height'];
}
/** @prop width {string} Mirrors the width attribute. */
get width() { return this.getAttribute('width'); }
set width(val) { this.setAttribute('width', val); }
/** @prop height {string} Mirrors the height attribute. */
get height() { return this.getAttribute('height'); }
set height(val) { this.setAttribute('height', val); }
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this._render();
}
}
});