modules/frontend/app/components/ignite-chart/controller.js (274 lines of code) (raw):

/* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import _ from 'lodash'; import moment from 'moment'; /** * @typedef {{x: number, y: {[key: string]: number}}} IgniteChartDataPoint */ const RANGE_RATE_PRESET = [ {label: '1 min', value: 1}, {label: '5 min', value: 5}, {label: '10 min', value: 10}, {label: '15 min', value: 15}, {label: '30 min', value: 30} ]; /** * Determines what label format was chosen by determineLabelFormat function * in Chart.js streaming plugin. * * @param {string} label */ const inferLabelFormat = (label) => { if (label.match(/\.\d{3} (am|pm)$/)) return 'MMM D, YYYY h:mm:ss.SSS a'; if (label.match(/:\d{1,2} (am|pm)$/)) return 'MMM D, YYYY h:mm:ss a'; if (label.match(/ \d{4}$/)) return 'MMM D, YYYY'; }; export class IgniteChartController { /** @type {import('chart.js').ChartConfiguration} */ chartOptions; /** @type {string} */ chartTitle; /** @type {IgniteChartDataPoint} */ chartDataPoint; /** @type {Array<IgniteChartDataPoint>} */ chartHistory; newPoints = []; static $inject = ['$element', 'IgniteChartColors', '$filter']; /** * @param {JQLite} $element * @param {Array<string>} IgniteChartColors * @param {ng.IFilterService} $filter */ constructor($element, IgniteChartColors, $filter) { this.$element = $element; this.IgniteChartColors = IgniteChartColors; this.datePipe = $filter('date'); this.ranges = RANGE_RATE_PRESET; this.currentRange = this.ranges[0]; this.maxRangeInMilliseconds = RANGE_RATE_PRESET[RANGE_RATE_PRESET.length - 1].value * 60 * 1000; this.ctx = this.$element.find('canvas')[0].getContext('2d'); this.localHistory = []; this.updateIsBusy = false; } $onDestroy() { if (this.chart) this.chart.destroy(); this.$element = this.ctx = this.chart = null; } $onInit() { this.chartColors = _.get(this.chartOptions, 'chartColors', this.IgniteChartColors); } _refresh() { this.onRefresh(); this.rerenderChart(); } /** * @param {{chartOptions: ng.IChangesObject<import('chart.js').ChartConfiguration>, chartTitle: ng.IChangesObject<string>, chartDataPoint: ng.IChangesObject<IgniteChartDataPoint>, chartHistory: ng.IChangesObject<Array<IgniteChartDataPoint>>}} changes */ async $onChanges(changes) { if (this.chart && _.get(changes, 'refreshRate.currentValue')) this.onRefreshRateChanged(_.get(changes, 'refreshRate.currentValue')); if ((changes.chartDataPoint && _.isNil(changes.chartDataPoint.currentValue)) || (changes.chartHistory && _.isEmpty(changes.chartHistory.currentValue))) { this.clearDatasets(); this.localHistory = []; return; } if (changes.chartHistory && changes.chartHistory.currentValue && changes.chartHistory.currentValue.length !== changes.chartHistory.previousValue.length) { if (!this.chart) await this.initChart(); this.clearDatasets(); this.localHistory = [...changes.chartHistory.currentValue]; this.newPoints.splice(0, this.newPoints.length, ...changes.chartHistory.currentValue); this._refresh(); return; } if (this.chartDataPoint && changes.chartDataPoint) { if (!this.chart) this.initChart(); this.newPoints.push(this.chartDataPoint); this.localHistory.push(this.chartDataPoint); this._refresh(); } } async initChart() { /** @type {import('chart.js').ChartConfiguration} */ this.config = { type: 'LineWithVerticalCursor', data: { datasets: [] }, options: { elements: { line: { tension: 0 }, point: { radius: 2, pointStyle: 'rectRounded' } }, animation: { duration: 0 // general animation time }, hover: { animationDuration: 0 // duration of animations when hovering an item }, responsiveAnimationDuration: 0, // animation duration after a resize maintainAspectRatio: false, responsive: true, legend: { display: false }, scales: { xAxes: [{ type: 'realtime', display: true, time: { displayFormats: { second: 'HH:mm:ss', minute: 'HH:mm:ss', hour: 'HH:mm:ss' } }, ticks: { maxRotation: 0, minRotation: 0 } }], yAxes: [{ type: 'linear', display: true, ticks: { min: 0, beginAtZero: true, maxTicksLimit: 4, callback: (value, index, labels) => { if (value === 0) return 0; if (_.max(labels) <= 4000 && value <= 4000) return value; if (_.max(labels) <= 1000000 && value <= 1000000) return `${value / 1000}K`; if ((_.max(labels) <= 4000000 && value >= 500000) || (_.max(labels) > 4000000)) return `${value / 1000000}M`; return value; } } }] }, tooltips: { mode: 'index', position: 'yCenter', intersect: false, yAlign: 'center', xPadding: 20, yPadding: 20, bodyFontSize: 13, callbacks: { title: (tooltipItem) => { return tooltipItem[0].xLabel = moment(tooltipItem[0].xLabel, inferLabelFormat(tooltipItem[0].xLabel)).format('HH:mm:ss'); }, label: (tooltipItem, data) => { const label = data.datasets[tooltipItem.datasetIndex].label || ''; return `${_.startCase(label)}: ${tooltipItem.yLabel} per sec`; }, labelColor: (tooltipItem) => { return { borderColor: 'rgba(255,255,255,0.5)', borderWidth: 0, boxShadow: 'none', backgroundColor: this.chartColors[tooltipItem.datasetIndex] }; } } }, plugins: { streaming: { duration: this.currentRange.value * 1000 * 60, frameRate: 1000 / this.refreshRate || 1 / 3, refresh: this.refreshRate || 3000, // Temporary workaround before https://github.com/nagix/chartjs-plugin-streaming/issues/53 resolved. // ttl: this.maxRangeInMilliseconds, onRefresh: () => { this.onRefresh(); } } } } }; this.config = _.merge(this.config, this.chartOptions); const chartModule = await import('chart.js'); const Chart = chartModule.default; Chart.Tooltip.positioners.yCenter = (elements) => { const chartHeight = elements[0]._chart.height; const tooltipHeight = 60; return {x: elements[0].getCenterPoint().x, y: Math.floor(chartHeight / 2) - Math.floor(tooltipHeight / 2) }; }; // Drawing vertical cursor Chart.defaults.LineWithVerticalCursor = Chart.defaults.line; Chart.controllers.LineWithVerticalCursor = Chart.controllers.line.extend({ draw(ease) { Chart.controllers.line.prototype.draw.call(this, ease); if (this.chart.tooltip._active && this.chart.tooltip._active.length) { const activePoint = this.chart.tooltip._active[0]; const ctx = this.chart.ctx; const x = activePoint.tooltipPosition().x; const topY = this.chart.scales['y-axis-0'].top; const bottomY = this.chart.scales['y-axis-0'].bottom; // draw line ctx.save(); ctx.beginPath(); ctx.moveTo(x, topY); ctx.lineTo(x, bottomY); ctx.lineWidth = 0.5; ctx.strokeStyle = '#0080ff'; ctx.stroke(); ctx.restore(); } } }); await import('chartjs-plugin-streaming'); this.chart = new Chart(this.ctx, this.config); this.changeXRange(this.currentRange); } onRefresh() { this.newPoints.forEach((point) => { this.appendChartPoint(point); }); this.newPoints.splice(0, this.newPoints.length); } /** * @param {IgniteChartDataPoint} dataPoint */ appendChartPoint(dataPoint) { Object.keys(dataPoint.y).forEach((key) => { if (this.checkDatasetCanBeAdded(key)) { let datasetIndex = this.findDatasetIndex(key); if (datasetIndex < 0) { datasetIndex = this.config.data.datasets.length; this.addDataset(key); } this.config.data.datasets[datasetIndex].data.push({x: dataPoint.x, y: dataPoint.y[key]}); this.config.data.datasets[datasetIndex].borderColor = this.chartColors[datasetIndex]; this.config.data.datasets[datasetIndex].borderWidth = 2; this.config.data.datasets[datasetIndex].fill = false; } }); // Temporary workaround before https://github.com/nagix/chartjs-plugin-streaming/issues/53 resolved. this.pruneHistory(); } // Temporary workaround before https://github.com/nagix/chartjs-plugin-streaming/issues/53 resolved. pruneHistory() { if (!this.xRangeUpdateInProgress) { const currenTime = Date.now(); while (currenTime - this.localHistory[0].x > this.maxRangeInMilliseconds) this.localHistory.shift(); this.config.data.datasets.forEach((dataset) => { while (currenTime - dataset.data[0].x > this.maxRangeInMilliseconds) dataset.data.shift(); }); } } /** * Checks if a key of dataset can be added to chart or should be ignored. * @param dataPointKey {String} * @return {Boolean} */ checkDatasetCanBeAdded(dataPointKey) { // If datasetLegendMapping is empty all keys are allowed. if (!this.config.datasetLegendMapping) return true; return Object.keys(this.config.datasetLegendMapping).includes(dataPointKey); } clearDatasets() { if (!_.isNil(this.config)) this.config.data.datasets.forEach((dataset) => dataset.data = []); } addDataset(datasetName) { if (this.findDatasetIndex(datasetName) >= 0) throw new Error(`Dataset with name ${datasetName} is already in chart`); else { const datasetIsHidden = _.isNil(this.config.datasetLegendMapping[datasetName].hidden) ? false : this.config.datasetLegendMapping[datasetName].hidden; this.config.data.datasets.push({ label: datasetName, data: [], hidden: datasetIsHidden }); } } findDatasetIndex(searchedDatasetLabel) { return this.config.data.datasets.findIndex((dataset) => dataset.label === searchedDatasetLabel); } changeXRange(range) { if (this.chart) { this.xRangeUpdateInProgress = true; this.chart.config.options.plugins.streaming.duration = range.value * 60 * 1000; this.clearDatasets(); this.newPoints.splice(0, this.newPoints.length, ...this.localHistory); this.onRefresh(); this.rerenderChart(); this.xRangeUpdateInProgress = false; } } onRefreshRateChanged(refreshRate) { this.chart.config.options.plugins.streaming.frameRate = 1000 / refreshRate; this.chart.config.options.plugins.streaming.refresh = refreshRate; this.rerenderChart(); } rerenderChart() { if (this.chart) this.chart.update(); } }