blob: 2b42bfdd2989c328bc137bfd37a57f01ca4885d4 [file] [log] [blame]
class TimeSeriesChart extends ComponentBase {
constructor(sourceList, options)
{
super();
this._canvas = null;
this._sourceList = sourceList;
this._trendLines = null;
this._options = options;
this._fetchedTimeSeries = null;
this._sampledTimeSeriesData = null;
this._renderedTrendLines = false;
this._valueRangeCache = null;
this._annotations = null;
this._annotationRows = null;
this._startTime = null;
this._endTime = null;
this._width = null;
this._height = null;
this._contextScaleX = 1;
this._contextScaleY = 1;
this._rem = null;
}
_ensureCanvas()
{
if (!this._canvas) {
this._canvas = this._createCanvas();
this._canvas.style.display = 'block';
this._canvas.style.position = 'absolute';
this._canvas.style.left = '0px';
this._canvas.style.top = '0px';
this.content().appendChild(this._canvas);
}
return this._canvas;
}
static cssTemplate()
{
return `
:host {
display: block !important;
position: relative !important;
}
`;
}
static get enqueueToRenderOnResize() { return true; }
_createCanvas()
{
return document.createElement('canvas');
}
setDomain(startTime, endTime)
{
console.assert(startTime < endTime, 'startTime must be before endTime');
this._startTime = startTime;
this._endTime = endTime;
this._fetchedTimeSeries = null;
this.fetchMeasurementSets(false);
}
setSourceList(sourceList)
{
this._sourceList = sourceList;
this._fetchedTimeSeries = null;
this.fetchMeasurementSets(false);
}
sourceList() { return this._sourceList; }
clearTrendLines()
{
this._trendLines = null;
this._renderedTrendLines = false;
this.enqueueToRender();
}
setTrendLine(sourceIndex, trendLine)
{
if (this._trendLines)
this._trendLines = this._trendLines.slice(0);
else
this._trendLines = [];
this._trendLines[sourceIndex] = trendLine;
this._renderedTrendLines = false;
this.enqueueToRender();
}
fetchMeasurementSets(noCache)
{
var fetching = false;
for (var source of this._sourceList) {
if (source.measurementSet) {
if (!noCache && source.measurementSet.hasFetchedRange(this._startTime, this._endTime))
continue;
source.measurementSet.fetchBetween(this._startTime, this._endTime, this._didFetchMeasurementSet.bind(this, source.measurementSet), noCache);
fetching = true;
}
}
this._sampledTimeSeriesData = null;
this._valueRangeCache = null;
this._annotationRows = null;
if (!fetching)
this.enqueueToRender();
}
_didFetchMeasurementSet(set)
{
this._fetchedTimeSeries = null;
this._sampledTimeSeriesData = null;
this._valueRangeCache = null;
this._annotationRows = null;
this.enqueueToRender();
}
// FIXME: Figure out a way to make this readonly.
sampledTimeSeriesData(type)
{
if (!this._sampledTimeSeriesData)
return null;
for (var i = 0; i < this._sourceList.length; i++) {
if (this._sourceList[i].type == type)
return this._sampledTimeSeriesData[i];
}
return null;
}
referencePoints(type)
{
const view = this.sampledTimeSeriesData(type);
if (!view || !this._startTime || !this._endTime)
return null;
const point = view.lastPointInTimeRange(this._startTime, this._endTime);
if (!point)
return null;
return {view, currentPoint: point, previousPoint: null};
}
setAnnotations(annotations)
{
this._annotations = annotations;
this._annotationRows = null;
this.enqueueToRender();
}
render()
{
if (!this._startTime || !this._endTime)
return;
var canvas = this._ensureCanvas();
var metrics = this._layout();
if (!metrics.doneWork)
return;
Instrumentation.startMeasuringTime('TimeSeriesChart', 'render');
var context = canvas.getContext('2d');
context.scale(this._contextScaleX, this._contextScaleY);
context.clearRect(0, 0, this._width, this._height);
context.font = metrics.fontSize + 'px sans-serif';
const axis = this._options.axis;
if (axis) {
context.strokeStyle = axis.gridStyle;
context.lineWidth = 1 / this._contextScaleY;
this._renderXAxis(context, metrics, this._startTime, this._endTime);
this._renderYAxis(context, metrics, this._valueRangeCache[0], this._valueRangeCache[1]);
}
context.save();
context.beginPath();
context.rect(metrics.chartX, metrics.chartY, metrics.chartWidth, metrics.chartHeight);
context.clip();
this._renderChartContent(context, metrics);
context.restore();
context.setTransform(1, 0, 0, 1, 0, 0);
Instrumentation.endMeasuringTime('TimeSeriesChart', 'render');
}
_layout()
{
// FIXME: We should detect changes in _options and _sourceList.
// FIXME: We should consider proactively preparing time series caches to avoid jaggy scrolling.
var doneWork = this._updateCanvasSizeIfClientSizeChanged();
var metrics = this._computeHorizontalRenderingMetrics();
doneWork |= this._ensureSampledTimeSeries(metrics);
doneWork |= this._ensureTrendLines();
doneWork |= this._ensureValueRangeCache();
this._computeVerticalRenderingMetrics(metrics);
doneWork |= this._layoutAnnotationBars(metrics);
metrics.doneWork = doneWork;
return metrics;
}
_computeHorizontalRenderingMetrics()
{
// FIXME: We should detect when rem changes.
if (!this._rem)
this._rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
var timeDiff = this._endTime - this._startTime;
var startTime = this._startTime;
const axis = this._options.axis || {};
const fontSize = (axis.fontSize || 1) * this._rem;
const chartX = (axis.yAxisWidth || 0) * fontSize;
var chartY = 0;
var chartWidth = this._width - chartX;
var chartHeight = this._height - (axis.xAxisHeight || 0) * fontSize;
if (axis.xAxisEndPadding)
timeDiff += axis.xAxisEndPadding / (chartWidth / timeDiff);
return {
xToTime: function (x)
{
var time = (x - chartX) / (chartWidth / timeDiff) + +startTime;
console.assert(Math.abs(x - this.timeToX(time)) < 1e-6);
return time;
},
timeToX: function (time) { return (chartWidth / timeDiff) * (time - startTime) + chartX; },
valueToY: function (value)
{
return ((chartHeight - this.annotationHeight) / this.valueDiff) * (this.endValue - value) + chartY;
},
chartX: chartX,
chartY: chartY,
chartWidth: chartWidth,
chartHeight: chartHeight,
annotationHeight: 0, // Computed later in _layoutAnnotationBars.
fontSize: fontSize,
valueDiff: 0,
endValue: 0,
};
}
_computeVerticalRenderingMetrics(metrics)
{
var minValue = this._valueRangeCache[0];
var maxValue = this._valueRangeCache[1];
var valueDiff = maxValue - minValue;
var valueMargin = valueDiff * 0.05;
var endValue = maxValue + valueMargin;
var valueDiffWithMargin = valueDiff + valueMargin * 2;
metrics.valueDiff = valueDiffWithMargin;
metrics.endValue = endValue;
}
_layoutAnnotationBars(metrics)
{
if (!this._annotations || !this._options.annotations)
return false;
var barHeight = this._options.annotations.barHeight;
var barSpacing = this._options.annotations.barSpacing;
if (this._annotationRows) {
metrics.annotationHeight = this._annotationRows.length * (barHeight + barSpacing);
return false;
}
Instrumentation.startMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
var minWidth = this._options.annotations.minWidth;
// (1) Expand the width of each bar to hit the minimum width and sort them by left edges.
this._annotations.forEach(function (annotation) {
var x1 = metrics.timeToX(annotation.startTime);
var x2 = metrics.timeToX(annotation.endTime);
if (x2 - x1 < minWidth) {
x1 -= minWidth / 2;
x2 += minWidth / 2;
}
annotation.x = x1;
annotation.width = x2 - x1;
});
var sortedAnnotations = this._annotations.sort(function (a, b) { return a.x - b.x });
// (2) For each bar, find the first row in which the last bar's right edge appear
// on the left of the bar as each row contains non-overlapping bars in the acending x order.
var rows = [];
sortedAnnotations.forEach(function (currentItem) {
for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
var currentRow = rows[rowIndex];
var lastItem = currentRow[currentRow.length - 1];
if (lastItem.x + lastItem.width + minWidth < currentItem.x) {
currentRow.push(currentItem);
return;
}
}
rows.push([currentItem]);
});
this._annotationRows = rows;
for (var rowIndex = 0; rowIndex < rows.length; rowIndex++) {
for (var annotation of rows[rowIndex]) {
annotation.y = metrics.chartY + metrics.chartHeight - (rows.length - rowIndex) * (barHeight + barSpacing);
annotation.height = barHeight;
}
}
metrics.annotationHeight = rows.length * (barHeight + barSpacing);
Instrumentation.endMeasuringTime('TimeSeriesChart', 'layoutAnnotationBars');
return true;
}
_renderXAxis(context, metrics, startTime, endTime)
{
var typicalWidth = context.measureText('12/31 x').width;
var maxXAxisLabels = Math.floor(metrics.chartWidth / typicalWidth);
var xAxisGrid = TimeSeriesChart.computeTimeGrid(startTime, endTime, maxXAxisLabels);
for (var item of xAxisGrid) {
context.beginPath();
var x = metrics.timeToX(item.time);
context.moveTo(x, metrics.chartY);
context.lineTo(x, metrics.chartY + metrics.chartHeight);
context.stroke();
}
if (!this._options.axis.xAxisHeight)
return;
var rightEdgeOfPreviousItem = 0;
for (var item of xAxisGrid) {
var xCenter = metrics.timeToX(item.time);
var width = context.measureText(item.label).width;
var x = xCenter - width / 2;
if (x + width > metrics.chartX + metrics.chartWidth) {
x = metrics.chartX + metrics.chartWidth - width;
if (x <= rightEdgeOfPreviousItem)
break;
}
rightEdgeOfPreviousItem = x + width;
context.fillText(item.label, x, metrics.chartY + metrics.chartHeight + metrics.fontSize);
}
}
_renderYAxis(context, metrics, minValue, maxValue)
{
const maxYAxisLabels = Math.floor(metrics.chartHeight / metrics.fontSize / 2);
const yAxisGrid = TimeSeriesChart.computeValueGrid(minValue, maxValue, maxYAxisLabels, this._options.axis.valueFormatter);
for (let item of yAxisGrid) {
context.beginPath();
const y = metrics.valueToY(item.value);
context.moveTo(metrics.chartX, y);
context.lineTo(metrics.chartX + metrics.chartWidth, y);
context.stroke();
}
if (!this._options.axis.yAxisWidth)
return;
for (let item of yAxisGrid) {
const x = (metrics.chartX - context.measureText(item.label).width) / 2;
let y = metrics.valueToY(item.value) + metrics.fontSize / 2.5;
if (y < metrics.fontSize)
y = metrics.fontSize;
context.fillText(item.label, x, y);
}
}
_renderChartContent(context, metrics)
{
context.lineJoin = 'round';
for (var i = 0; i < this._sourceList.length; i++) {
var source = this._sourceList[i];
var series = this._sampledTimeSeriesData[i];
if (series)
this._renderTimeSeries(context, metrics, source, series, this._trendLines && this._trendLines[i] ? 'background' : '');
}
for (var i = 0; i < this._sourceList.length; i++) {
var source = this._sourceList[i];
var trendLine = this._trendLines ? this._trendLines[i] : null;
if (series && trendLine)
this._renderTimeSeries(context, metrics, source, trendLine, 'foreground');
}
if (!this._annotationRows)
return;
for (var row of this._annotationRows) {
for (var bar of row) {
if (bar.x > this.chartWidth || bar.x + bar.width < 0)
continue;
context.fillStyle = bar.fillStyle;
context.fillRect(bar.x, bar.y, bar.width, bar.height);
}
}
}
_renderTimeSeries(context, metrics, source, series, layerName)
{
for (var point of series) {
point.x = metrics.timeToX(point.time);
point.y = metrics.valueToY(point.value);
}
if (source.intervalStyle) {
context.strokeStyle = source.intervalStyle;
context.fillStyle = source.intervalStyle;
context.lineWidth = source.intervalWidth;
context.beginPath();
var width = 1;
for (var i = 0; i < series.length; i++) {
var point = series[i];
var interval = point.interval;
var value = interval ? interval[0] : point.value;
context.lineTo(point.x - width, metrics.valueToY(value));
context.lineTo(point.x + width, metrics.valueToY(value));
}
for (var i = series.length - 1; i >= 0; i--) {
var point = series[i];
var interval = point.interval;
var value = interval ? interval[1] : point.value;
context.lineTo(point.x + width, metrics.valueToY(value));
context.lineTo(point.x - width, metrics.valueToY(value));
}
context.fill();
}
context.strokeStyle = this._sourceOptionWithFallback(source, layerName + 'LineStyle', 'lineStyle');
context.lineWidth = this._sourceOptionWithFallback(source, layerName + 'LineWidth', 'lineWidth');
context.beginPath();
for (var point of series)
context.lineTo(point.x, point.y);
context.stroke();
context.fillStyle = this._sourceOptionWithFallback(source, layerName + 'PointStyle', 'pointStyle');
var radius = this._sourceOptionWithFallback(source, layerName + 'PointRadius', 'pointRadius');
if (radius) {
for (var point of series)
this._fillCircle(context, point.x, point.y, radius);
}
}
_sourceOptionWithFallback(option, preferred, fallback)
{
return preferred in option ? option[preferred] : option[fallback];
}
_fillCircle(context, cx, cy, radius)
{
context.beginPath();
context.arc(cx, cy, radius, 0, 2 * Math.PI);
context.fill();
}
_ensureFetchedTimeSeries()
{
if (this._fetchedTimeSeries)
return false;
Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
this._fetchedTimeSeries = this._sourceList.map(function (source) {
return source.measurementSet.fetchedTimeSeries(source.type, source.includeOutliers, source.extendToFuture);
});
Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureFetchedTimeSeries');
return true;
}
_ensureSampledTimeSeries(metrics)
{
if (this._sampledTimeSeriesData)
return false;
this._ensureFetchedTimeSeries();
Instrumentation.startMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
const startTime = this._startTime;
const endTime = this._endTime;
this._sampledTimeSeriesData = this._sourceList.map((source, sourceIndex) => {
const timeSeries = this._fetchedTimeSeries[sourceIndex];
if (!timeSeries)
return null;
const pointAfterStart = timeSeries.findPointAfterTime(startTime);
const pointBeforeStart = (pointAfterStart ? timeSeries.previousPoint(pointAfterStart) : null) || timeSeries.firstPoint();
const pointAfterEnd = timeSeries.findPointAfterTime(endTime) || timeSeries.lastPoint();
if (!pointBeforeStart || !pointAfterEnd)
return null;
// FIXME: Move this to TimeSeries.prototype.
const view = timeSeries.viewBetweenPoints(pointBeforeStart, pointAfterEnd);
if (!source.sampleData)
return view;
// A chart with X px width shouldn't have more than 2X / <radius-of-points> data points.
const viewWidth = Math.min(metrics.chartWidth, metrics.timeToX(pointAfterEnd.time) - metrics.timeToX(pointBeforeStart.time));
const maximumNumberOfPoints = 2 * viewWidth / (source.pointRadius || 2);
return this._sampleTimeSeries(view, maximumNumberOfPoints, new Set);
});
Instrumentation.endMeasuringTime('TimeSeriesChart', 'ensureSampledTimeSeries');
this.dispatchAction('dataChange');
return true;
}
_sampleTimeSeries(view, maximumNumberOfPoints, excludedPoints)
{
if (view.length() < 2 || maximumNumberOfPoints >= view.length() || maximumNumberOfPoints < 1)
return view;
Instrumentation.startMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
let ranks = new Array(view.length());
let i = 0;
for (let point of view) {
let previousPoint = view.previousPoint(point) || point;
let nextPoint = view.nextPoint(point) || point;
ranks[i] = nextPoint.time - previousPoint.time;
i++;
}
const sortedRanks = ranks.slice(0).sort((a, b) => b - a);
const minimumRank = sortedRanks[Math.floor(maximumNumberOfPoints)];
const sampledData = view.filter((point, i) => {
return excludedPoints.has(point.id) || ranks[i] >= minimumRank;
});
Instrumentation.endMeasuringTime('TimeSeriesChart', 'sampleTimeSeries');
Instrumentation.reportMeasurement('TimeSeriesChart', 'samplingRatio', '%', sampledData.length() / view.length() * 100);
return sampledData;
}
_ensureTrendLines()
{
if (this._renderedTrendLines)
return false;
this._renderedTrendLines = true;
return true;
}
_ensureValueRangeCache()
{
if (this._valueRangeCache)
return false;
Instrumentation.startMeasuringTime('TimeSeriesChart', 'valueRangeCache');
var startTime = this._startTime;
var endTime = this._endTime;
var min;
var max;
for (var seriesData of this._sampledTimeSeriesData) {
if (!seriesData)
continue;
for (var point of seriesData) {
var minCandidate = point.interval ? point.interval[0] : point.value;
var maxCandidate = point.interval ? point.interval[1] : point.value;
min = (min === undefined) ? minCandidate : Math.min(min, minCandidate);
max = (max === undefined) ? maxCandidate : Math.max(max, maxCandidate);
}
}
if (min == max)
max = max * 1.1;
this._valueRangeCache = [min, max];
Instrumentation.endMeasuringTime('TimeSeriesChart', 'valueRangeCache');
return true;
}
_updateCanvasSizeIfClientSizeChanged()
{
var canvas = this._ensureCanvas();
var newWidth = this.element().clientWidth;
var newHeight = this.element().clientHeight;
if (!newWidth || !newHeight || (newWidth == this._width && newHeight == this._height))
return false;
var scale = window.devicePixelRatio;
canvas.width = newWidth * scale;
canvas.height = newHeight * scale;
canvas.style.width = newWidth + 'px';
canvas.style.height = newHeight + 'px';
this._contextScaleX = scale;
this._contextScaleY = scale;
this._width = newWidth;
this._height = newHeight;
this._sampledTimeSeriesData = null;
this._annotationRows = null;
return true;
}
static computeTimeGrid(min, max, maxLabels)
{
const diffPerLabel = (max - min) / maxLabels;
let iterator;
for (iterator of this._timeIterators()) {
if (iterator.diff >= diffPerLabel)
break;
}
console.assert(iterator);
const currentTime = new Date(min);
currentTime.setUTCMilliseconds(0);
currentTime.setUTCSeconds(0);
currentTime.setUTCMinutes(0);
iterator.next(currentTime);
const fitsInOneDay = max - min < 24 * 3600 * 1000;
let result = [];
let previousDate = null;
let previousMonth = null;
while (currentTime <= max && result.length < maxLabels) {
const time = new Date(currentTime);
const month = time.getUTCMonth() + 1;
const date = time.getUTCDate();
const hour = time.getUTCHours();
const hourLabel = ((hour % 12) || 12) + (hour >= 12 ? 'PM' : 'AM');
iterator.next(currentTime);
let label;
const isMidnight = !hour;
if ((date == previousDate && month == previousMonth) || ((!isMidnight || previousDate == null) && fitsInOneDay))
label = hourLabel;
else {
label = `${month}/${date}`;
if (!isMidnight && currentTime.getUTCDate() != date)
label += ' ' + hourLabel;
}
result.push({time: time, label: label});
previousDate = date;
previousMonth = month;
}
console.assert(result.length <= maxLabels);
return result;
}
static _timeIterators()
{
var HOUR = 3600 * 1000;
var DAY = 24 * HOUR;
return [
{
diff: 2 * HOUR,
next: function (date) {
if (date.getUTCHours() >= 22) {
date.setUTCHours(0);
date.setUTCDate(date.getUTCDate() + 1);
} else
date.setUTCHours(Math.floor(date.getUTCHours() / 2) * 2 + 2);
},
},
{
diff: 12 * HOUR,
next: function (date) {
if (date.getUTCHours() >= 12) {
date.setUTCHours(0);
date.setUTCDate(date.getUTCDate() + 1);
} else
date.setUTCHours(12);
},
},
{
diff: DAY,
next: function (date) {
date.setUTCHours(0);
date.setUTCDate(date.getUTCDate() + 1);
}
},
{
diff: 2 * DAY,
next: function (date) {
date.setUTCHours(0);
date.setUTCDate(date.getUTCDate() + 2);
}
},
{
diff: 7 * DAY,
next: function (date) {
date.setUTCHours(0);
if (date.getUTCDay())
date.setUTCDate(date.getUTCDate() + (7 - date.getUTCDay()));
else
date.setUTCDate(date.getUTCDate() + 7);
}
},
{
diff: 16 * DAY,
next: function (date) {
date.setUTCHours(0);
if (date.getUTCDate() >= 15) {
date.setUTCMonth(date.getUTCMonth() + 1);
date.setUTCDate(1);
} else
date.setUTCDate(15);
}
},
{
diff: 31 * DAY,
next: function (date) {
date.setUTCHours(0);
const dayOfMonth = date.getUTCDate();
if (dayOfMonth > 1 && dayOfMonth < 15)
date.setUTCDate(15);
else {
if (dayOfMonth != 15)
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + 1);
}
}
},
{
diff: 60 * DAY,
next: function (date) {
date.setUTCHours(0);
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + 2);
}
},
{
diff: 90 * DAY,
next: function (date) {
date.setUTCHours(0);
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + 3);
}
},
{
diff: 120 * DAY,
next: function (date) {
date.setUTCHours(0);
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + 4);
}
},
];
}
static computeValueGrid(min, max, maxLabels, formatter)
{
const diff = max - min;
if (!diff)
return [];
const diffPerLabel = diff / maxLabels;
// First, reduce the diff between 1 and 1000 using a power of 1000 or 1024.
// FIXME: Share this code with Metric.makeFormatter.
const maxAbsValue = Math.max(Math.abs(min), Math.abs(max));
let scalingFactor = 1;
const divisor = formatter.divisor;
while (maxAbsValue * scalingFactor < 1)
scalingFactor *= formatter.divisor;
while (maxAbsValue * scalingFactor > divisor)
scalingFactor /= formatter.divisor;
const scaledDiff = diffPerLabel * scalingFactor;
// Second, compute the smallest number greater than the scaled diff
// which is a product of a power of 10, 2, and 5.
// These numbers are all factors of the decimal numeral system, 10.
const digitsInScaledDiff = Math.ceil(Math.log(scaledDiff) / Math.log(10));
let step = Math.pow(10, digitsInScaledDiff);
if (step / 5 >= scaledDiff)
step /= 5; // The most significant digit is 2
else if (step / 2 >= scaledDiff)
step /= 2 // The most significant digit is 5
step /= scalingFactor;
const gridValues = [];
let currentValue = Math.ceil(min / step) * step;
while (currentValue <= max) {
let unscaledValue = currentValue;
gridValues.push({value: unscaledValue, label: formatter(unscaledValue, maxAbsValue)});
currentValue += step;
}
return gridValues;
}
}
ComponentBase.defineElement('time-series-chart', TimeSeriesChart);