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