From f8c362773dab1a2ffc20841a9819ccac36f0e912 Mon Sep 17 00:00:00 2001 From: M Starch Date: Thu, 29 Jul 2021 09:48:46 -0700 Subject: [PATCH 1/5] lestarch: final pre-look at realtime high-rate charting --- .../addons/chart-display/addon-templates.js | 96 ++ .../static/addons/chart-display/addon.js | 166 +++ .../addons/chart-display/chart-addon.js | 468 --------- .../static/addons/chart-display/config.js | 96 ++ .../chartjs-plugin-streaming.js | 971 ++++++++++++++++++ .../modified-vendor/chartjs-plugin-zoom.js | 954 +++++++++++++++++ .../static/addons/chart-display/sibling.js | 77 ++ .../chart-display/vendor}/chart.js | 0 .../vendor}/chartjs-adapter-luxon.min.js | 0 .../chart-display/vendor}/hammer.min.js | 0 src/fprime_gds/flask/static/addons/enabled.js | 1 + src/fprime_gds/flask/static/js/datastore.js | 24 +- .../flask/static/js/vue-support/tabetc.js | 1 - .../flask/static/js/vue-support/utils.js | 4 +- .../js/chartjs-plugin-streaming.min.js | 7 - .../third-party/js/chartjs-plugin-zoom.min.js | 7 - 16 files changed, 2385 insertions(+), 487 deletions(-) create mode 100644 src/fprime_gds/flask/static/addons/chart-display/addon-templates.js create mode 100644 src/fprime_gds/flask/static/addons/chart-display/addon.js delete mode 100644 src/fprime_gds/flask/static/addons/chart-display/chart-addon.js create mode 100644 src/fprime_gds/flask/static/addons/chart-display/config.js create mode 100644 src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-streaming.js create mode 100644 src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-zoom.js create mode 100644 src/fprime_gds/flask/static/addons/chart-display/sibling.js rename src/fprime_gds/flask/static/{third-party/js => addons/chart-display/vendor}/chart.js (100%) rename src/fprime_gds/flask/static/{third-party/js => addons/chart-display/vendor}/chartjs-adapter-luxon.min.js (100%) rename src/fprime_gds/flask/static/{third-party/js => addons/chart-display/vendor}/hammer.min.js (100%) delete mode 100644 src/fprime_gds/flask/static/third-party/js/chartjs-plugin-streaming.min.js delete mode 100644 src/fprime_gds/flask/static/third-party/js/chartjs-plugin-zoom.min.js diff --git a/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js b/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js new file mode 100644 index 00000000..2e92f9b3 --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js @@ -0,0 +1,96 @@ + + +export let chart_wrapper_template = ` +
+ +
+
+ + +
+
+ +
+
+ + +
+ + + + +
+`; + +export let chart_display_template = ` +
+
+ +
+ + + {{ selected }} +
+ +
+ +
+
+ + +
+
+ +
+
+ + + +
+
+ +
+
+ +
+
+ +
+
+
+`; \ No newline at end of file diff --git a/src/fprime_gds/flask/static/addons/chart-display/addon.js b/src/fprime_gds/flask/static/addons/chart-display/addon.js new file mode 100644 index 00000000..3047560a --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/addon.js @@ -0,0 +1,166 @@ +/** + * addons/chart-display.js: + * + * Visualize selected telemetry channels using time series charts + * + * @author saba-ja + */ +import {generate_chart_config} from "./config.js"; +import {chart_wrapper_template, chart_display_template} from "./addon-templates.js"; +import { _datastore } from '../../js/datastore.js'; +import {_loader} from "../../js/loader.js"; +import {SiblingSet} from './sibling.js'; + +import './vendor/chart.js'; +import './vendor/chartjs-adapter-luxon.min.js'; +import './vendor/hammer.min.js'; +// Note: these are modified versions of the original plugin files +import './modified-vendor/chartjs-plugin-zoom.js'; +import './modified-vendor/chartjs-plugin-streaming.js'; + + +function timeToDate(time) { + let date = new Date((time.seconds * 1000) + (time.microseconds/1000)); + return date; +} + +/** + * Wrapper component to allow user add multiple charts to the same page + */ +Vue.component("chart-wrapper", { + data: function () { + return { + locked: false, + isHelpActive: true, + wrappers: [{"id": 0}], + siblings: new SiblingSet() + }; + }, + template: chart_wrapper_template, + methods: { + /** + * Add new chart + */ + addChart(type) { + this.wrappers.push({'id': this.counter}); + this.counter += 1; + }, + /** + * Remove chart with the given id + */ + deleteChart(id) { + const index = this.wrappers.findIndex(f => f.id === id); + this.wrappers.splice(index,1); + }, + } +}); + +/** + * Main chart component + */ +Vue.component("chart-display", { + template: chart_display_template, + props: ["id", "siblings"], + data: function () { + let names = Object.values(_loader.endpoints["channel-dict"].data).map((value) => {return value.full_name}); + + return { + channelNames: names, + selected: null, + oldSelected: null, + + isCollapsed: false, + pause: false, + + chart: null, + }; + }, + methods: { + /** + * Allow user to pause the chart stream + */ + toggleStreamFlow() { + const realtimeOpts = this.chart.options.scales.x.realtime; + realtimeOpts.pause = !realtimeOpts.pause; + this.pause = realtimeOpts.pause; + this.siblings.pause(realtimeOpts.pause); + }, + /** + * Register a new chart object + */ + registerChart() { + // If there is a chart object destroy it to reset the chart + this.destroy(); + _datastore.registerChannelConsumer(this); + let config = generate_chart_config(this.selected); + config.options.plugins.zoom.zoom.onZoom = this.siblings.syncToAll; + config.options.plugins.zoom.pan.onPan = this.siblings.syncToAll; + // Category IV magic: do not alter + config.options.scales.x.realtime.onRefresh = this.siblings.sync; + this.showControlBtns = true; + try { + this.chart = new Chart(this.$el.querySelector("#ds-line-chart"), config); + } catch(err) { + // Todo. This currently suppresses the following bug error + // See ChartJs bug report https://github.com/chartjs/Chart.js/issues/9368 + } + this.siblings.add(this.chart); + }, + /** + * Reset chart zoom back to default + */ + resetZoom() { + this.chart.resetZoom("none"); + this.siblings.reset(); + }, + destroy() { + // Guard against destroying that which is destroyed + if (this.chart == null) { + return; + } + _datastore.deregisterChannelConsumer(this); + this.chart.data.datasets.forEach((dataset) => {dataset.data = [];}); + this.chart.destroy(); + this.siblings.remove(this.chart); + this.chart = null; + }, + + /** + * sending message up to the parent to remove this chart with this id + * @param {int} id of current chart instance known to the parent + */ + emitDeleteChart(id) { + this.destroy(); + this.$emit('delete-chart', id); + }, + + sendChannels(channels) { + if (this.selected == null || this.chart == null) { + return; + } + let name = this.selected; + let new_channels = channels.filter((channel) => { + return channel.template.full_name == name + }); + new_channels = new_channels.map( + (channel) => { + return {x: timeToDate(channel.time), y: channel.val} + } + ); + + this.chart.data.datasets[0].data.push(...new_channels); + this.chart.update('quiet'); + } + }, + /** + * Watch for new selection of channel and re-register the chart + */ + watch: { + selected: function() { + if (this.selected !== this.oldSelected) { + this.oldSelected = this.selected; + this.registerChart(); + } + }, + } +}); diff --git a/src/fprime_gds/flask/static/addons/chart-display/chart-addon.js b/src/fprime_gds/flask/static/addons/chart-display/chart-addon.js deleted file mode 100644 index 370973e6..00000000 --- a/src/fprime_gds/flask/static/addons/chart-display/chart-addon.js +++ /dev/null @@ -1,468 +0,0 @@ -/** - * addons/chart-display.js: - * - * Visualize selected telemetry channels using time series charts - * - * @author saba-ja - */ - -import { _datastore } from '../../js/datastore.js'; -import '../../third-party/js/chart.js'; -import '../../third-party/js/chartjs-adapter-luxon.min.js'; -import '../../third-party/js/hammer.min.js'; -import '../../third-party/js/chartjs-plugin-zoom.min.js'; -import '../../third-party/js/chartjs-plugin-streaming.min.js'; - -/** - * Wrapper component to allow user add multiple charts to the same page - */ -Vue.component("chart-wrapper", { - data: function () { - return { - counter: 0, // Auto incrementing id of each chart box - chartInstances: [], // list of chart objects - }; - }, - template: ` -
- -
-
- -
-
- - - - -
- `, - - methods: { - /** - * Add new chart - */ - addChart: function (type) { - this.chartInstances.push({'id': this.counter, 'type': type}) - this.counter += 1; - }, - /** - * Remove chart with the given id - */ - deleteChart: function (id) { - const index = this.chartInstances.findIndex(f => f.id === id); - this.chartInstances.splice(index,1); - }, - } -}); - -/** - * Main chart component - */ -Vue.component("chart-display", { - template: ` -
-
- -
- - - - {{ channelName }} -
- -
- -
-
- - -
-
-
- -
-
- - - - -
- -
- -
-
- -
- -
-
- -
-
- -
-
-
- `, - props: ["id"], - data: function () { - return { - channels: _datastore.channels, - channelNames: [], - selected: null, - oldSelected: null, - channelLoaded: false, - - isCollapsed: false, - isHelpActive: false, - - chartObj: null, - channelId: null, - channelName: "", - channelTimestamp: null, - channelValue: null, - showControlBtns: false, - - // https://nagix.github.io/chartjs-plugin-streaming/2.0.0/guide/options.html - duration: 60000, // (1 min) Duration of the chart in milliseconds (how much time of data it will show). - ttl: 1800000, // (30 min) // Duration of the data to be kept in milliseconds. - delay: 2000, // (2 sec) Delay added to the chart in milliseconds so that upcoming values are known before lines are plotted. - refresh: 1000, // (1 sec) Refresh interval of data in milliseconds. onRefresh callback function will be called at this interval. - frameRate: 30, // Frequency at which the chart is drawn on a display - pause: false, // If set to true, scrolling stops. Note that onRefresh callback is called even when this is set to true. - reverse: false, // If true moves from left to right - animation: true, - responsive: true, - maintainAspectRatio: false, - intersect: false, - minDelay: 2000, // (2 sec) Min value of the delay option - maxDelay: 1800000, // (30 min) Max value of the delay option - minDuration: 2000, // (2 sec) Min value of the duration option - maxDuration: 1800000, // (30 min) Max value of the duration option - - config: {}, - }; - }, - - methods: { - - /** - * Extract channel name from channel object - */ - setChannelNames() { - if (this.channelLoaded || this.channels === undefined) { - return; - } - let ch_keys = Object.keys(this.channels); - if (ch_keys.length === 0) { - return; - } - this.channelNames = []; // reset channel names to avoid duplicates - for (let i = 0; i < ch_keys.length; i++) { - let ch = this.channels[ch_keys[i]]; - this.channelNames.push({ - option: ch.template.full_name, - id: ch.id, - }); - this.channelLoaded = true; - } - }, - - /** - * Function to update the chart with new data - */ - onRefresh(){ - this.chartObj.data.datasets[0].data.push({ - x: Date.now(), - y: this.channelValue, - }); - }, - - /** - * returns current status (enable/disable) of zooming with mouse wheel - */ - zoomStatus() { - if (this.chartObj) { - return 'Zoom: ' + (this.chartObj.options.plugins.zoom.zoom.wheel.enabled ? 'enabled' : 'disabled'); - } else { - return 'Zoom: ' + 'disabled'; - } - }, - - /** - * Allow user to pause the chart stream - */ - toggleStreamFlow() { - const realtimeOpts = this.chartObj.options.scales.x.realtime; - realtimeOpts.pause = !realtimeOpts.pause; - this.pause = !this.pause; - this.chartObj.update("none"); - }, - - /** - * Set chart configuration - */ - setConfig() { - this.config = { - type: "line", - data: { - datasets: [ - { - label: this.channelName, - backgroundColor: "rgba(54, 162, 235, 0.5)", - borderColor: "rgb(54, 162, 235)", - cubicInterpolationMode: "monotone", - data: [], - }, - ], - }, - options: { - animation: this.animation, - responsive: this.responsive, - maintainAspectRatio: this.maintainAspectRatio, - interaction: { - intersect: this.intersect - }, - onClick(e) { - const chart = e.chart; - chart.options.plugins.zoom.zoom.wheel.enabled = !chart.options.plugins.zoom.zoom.wheel.enabled; - chart.options.plugins.zoom.zoom.pinch.enabled = !chart.options.plugins.zoom.zoom.pinch.enabled; - chart.update(); - }, - scales: { - x: { - type: "realtime", - realtime: { - duration: this.duration, - ttl: this.ttl, - delay: this.delay, - refresh: this.refresh, - frameRate: this.frameRate, - pause: this.pause, - onRefresh: this.onRefresh - }, - reverse: this.reverse - }, - y: { - title: { - display: true, - text: "Value" - } - } - }, - plugins: { - zoom: { - // Assume x axis has the realtime scale - pan: { - enabled: true, // Enable panning - mode: "x", // Allow panning in the x direction - }, - zoom: { - pinch: { - enabled: false, // Enable pinch zooming - }, - wheel: { - enabled: false, // Enable wheel zooming - }, - mode: "x", // Allow zooming in the x direction - }, - limits: { - x: { - minDelay: this.minDelay, - maxDelay: this.maxDelay, - minDuration: this.minDuration, - maxDuration: this.maxDuration, - }, - }, - }, - title: { - display: true, - position: 'bottom', - text: this.zoomStatus // keep track of zoom enable status - }, - }, - }, - plugins:[ - // Highlight chart border when user clicks on the chart area - { - id: 'chartAreaBorder', - beforeDraw(chart, args, options) { - const {ctx, chartArea: {left, top, width, height}} = chart; - if (chart.options.plugins.zoom.zoom.wheel.enabled) { - ctx.save(); - ctx.strokeStyle = '#f5c6cb'; - ctx.lineWidth = 2; - ctx.strokeRect(left, top, width, height); - ctx.restore(); - } - } - } - ], - } - }, - - /** - * Register a new chart object - */ - registerChart() { - // If there is a chart object destroy it to reset the chart - if (this.chartObj !== null) { - this.chartObj.data.datasets.forEach((dataset) => { - dataset.data = []; - }); - this.chartObj.destroy(); - this.showControlBtns = false; - } - - // If the selected channel does not have any value do not register the chart - let id = this.selected.id; - if (this.isChannelOff(id)) { - return; - } - - this.channelName = this.getChannelName(id); - this.setConfig(); - this.showControlBtns = true; - try { - this.chartObj = new Chart( - this.$el.querySelector("#ds-line-chart"), - this.config - ); - } catch(err) { - // Todo. This currently suppresses the following bug error - // See ChartJs bug report https://github.com/chartjs/Chart.js/issues/9368 - } - }, - - /** - * Check whether there is any data in the channel - */ - isChannelOff(id) { - return this.channels[id].str === undefined; - }, - - getChannelName(id) { - return this.channels[id].template.full_name; - }, - - /** - * Reset chart zoom back to default - */ - resetZoom() { - this.chartObj.resetZoom("none"); - }, - - /** - * Allow user to collapse or open chart display box - */ - toggleCollapseChart() { - this.isCollapsed = !this.isCollapsed; - }, - - /** - * Show or remove alert box when user click on the help button - */ - toggleShowHelp() { - this.isHelpActive = !this.isHelpActive; - }, - - /** - * Remove alert box when user click on the close button of help alert - */ - dismissHelp () { - this.isHelpActive = false; - }, - - /** - * sending message up to the parent to remove this chart with this id - * @param {int} id of current chart instance known to the parent - */ - emitDeleteChart(id) { - if (this.chartObj) { - this.chartObj.destroy(); - } - this.$emit('delete-chart', id); - }, - }, - - mounted: function () { - this.setChannelNames(); - }, - - computed: { - updateData: function () { - this.setChannelNames(); - - if (this.selected === null) { - return; - } - let id = this.selected.id; - if (this.isChannelOff(id)) { - return; - } else { - this.channelId = this.channels[id].id; - this.channelName = this.channels[id].template.full_name; - this.channelTimestamp = this.channels[id].time.seconds; - this.channelValue = this.channels[id].val; - } - }, - }, - - /** - * Watch for new selection of channel and re-register the chart - */ - watch: { - selected: function() { - if (this.selected !== this.oldSelected) { - this.oldSelected = this.selected; - this.registerChart(); - } - }, - } -}); diff --git a/src/fprime_gds/flask/static/addons/chart-display/config.js b/src/fprime_gds/flask/static/addons/chart-display/config.js new file mode 100644 index 00000000..6ab40b8e --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/config.js @@ -0,0 +1,96 @@ +/** + * Configuration settings for the Chart JS plugin. + */ + + +export let chart_options = { + parsing: true, + animation: false, + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false + }, +}; + +export let dataset_config = { + normalized: false, + spanGaps: false, + backgroundColor: "rgba(54, 162, 235, 0.5)", + borderColor: "rgb(54, 162, 235)", + lineTension: 0, +}; + +export let ticks_config = { + autoSkip: true, + maxRotation: 0, + sampleSize: 10 +}; + +export let realtime_config = { + // Initial display width (ms): 1 min + duration: 60000, + // Total data history (ms): 4 min + ttl: 4 * 60 * 1000, + // Initial chart delay (ms): 0 + delay: 0, + // Drawing framerate (ms): 30 Hz + frameRate: 30, + // Start paused: false + pause: false, + // Refresh rate: 30 Hz + refresh: 100 +}; + + + +export let zoom_config = { + // Allows pan using the "shift" modifier key + pan: { + enabled: true, + mode: "xy", + modifierKey: "shift" + }, + // Allows zooming holding the "alt" key and scrolling over an axis or clicking and dragging a region + // Note: due to a bug in the zoom/streaming plugin interaction, clicking/dragging only affects the y axis + zoom: { + drag: { + enabled: true, + modifierKey: "alt" + }, + wheel: { + enabled: true, + modifierKey: "alt" + }, + // Allows zooming of both axises but only over the axis in question + mode: "xy", + overScaleMode: "xy", + }, + limits: { + // Initial limits for the realtime x axis set from maximum data stored + x: { + minDelay: 0, + maxDelay: realtime_config.ttl, + minDuration: 0, + maxDuration: realtime_config.ttl, + }, + }, +}; + +export function generate_chart_config(label) { + let final_realtime_config = Object.assign({}, realtime_config); + let scales = { + x: {type: "realtime", realtime: final_realtime_config, ticks: ticks_config}, + y: {title: {display: true, text: "Value"}} + }; + let plugins = {zoom: zoom_config}; + + let final_dataset_config = Object.assign({label: label}, dataset_config, {data: []}); + let final_options_config = Object.assign({}, chart_options, {"scales": scales, "plugins": plugins}); + + return { + type: "line", + data: {datasets: [final_dataset_config]}, + options: final_options_config + } +} \ No newline at end of file diff --git a/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-streaming.js b/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-streaming.js new file mode 100644 index 00000000..dd97dd0c --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-streaming.js @@ -0,0 +1,971 @@ +/** + * NOTE: this file was modified by M Starch from its original version. This added following: + * + * - setRealtimeScale() + * - plugin.updateRangeFunctions.realtime = setRealtimeScale; + * + * and is dependent on a modified chartjs-plugin-zoom.js. + */ +/*! + * chartjs-plugin-streaming v2.0.0 + * https://nagix.github.io/chartjs-plugin-streaming + * (c) 2017-2021 Akihiko Kusanagi + * Released under the MIT license + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js'), require('chart.js/helpers')) : +typeof define === 'function' && define.amd ? define(['chart.js', 'chart.js/helpers'], factory) : +(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ChartStreaming = factory(global.Chart, global.Chart.helpers)); +}(this, (function (chart_js, helpers) { 'use strict'; + +function clamp(value, lower, upper) { + return Math.min(Math.max(value, lower), upper); +} +function resolveOption(scale, key) { + const realtimeOpts = scale.options.realtime; + const streamingOpts = scale.chart.options.plugins.streaming; + return helpers.valueOrDefault(realtimeOpts[key], streamingOpts[key]); +} +function getAxisMap(element, {x, y}, {xAxisID, yAxisID}) { + const axisMap = {}; + helpers.each(x, key => { + axisMap[key] = {axisId: xAxisID}; + }); + helpers.each(y, key => { + axisMap[key] = {axisId: yAxisID}; + }); + return axisMap; +} +const cancelAnimFrame = (function() { + if (typeof window === 'undefined') { + return helpers.noop; + } + return window.cancelAnimationFrame; +}()); +function startFrameRefreshTimer(context, func) { + if (!context.frameRequestID) { + const refresh = () => { + const nextRefresh = context.nextRefresh || 0; + const now = Date.now(); + if (nextRefresh <= now) { + const newFrameRate = helpers.callback(func); + const frameDuration = 1000 / (Math.max(newFrameRate, 0) || 30); + const newNextRefresh = context.nextRefresh + frameDuration || 0; + context.nextRefresh = newNextRefresh > now ? newNextRefresh : now + frameDuration; + } + context.frameRequestID = helpers.requestAnimFrame.call(window, refresh); + }; + context.frameRequestID = helpers.requestAnimFrame.call(window, refresh); + } +} +function stopFrameRefreshTimer(context) { + const frameRequestID = context.frameRequestID; + if (frameRequestID) { + cancelAnimFrame.call(window, frameRequestID); + delete context.frameRequestID; + } +} +function stopDataRefreshTimer(context) { + const refreshTimerID = context.refreshTimerID; + if (refreshTimerID) { + clearInterval(refreshTimerID); + delete context.refreshTimerID; + delete context.refreshInterval; + } +} +function startDataRefreshTimer(context, func, interval) { + if (!context.refreshTimerID) { + context.refreshTimerID = setInterval(() => { + const newInterval = helpers.callback(func); + if (context.refreshInterval !== newInterval && !isNaN(newInterval)) { + stopDataRefreshTimer(context); + startDataRefreshTimer(context, func, newInterval); + } + }, interval || 0); + context.refreshInterval = interval || 0; + } +} + +function scaleValue(scale, value, fallback) { + value = typeof value === 'number' ? value : scale.parse(value); + return helpers.isFinite(value) ? + {value: scale.getPixelForValue(value), transitionable: true} : + {value: fallback}; +} +function updateBoxAnnotation(element, chart, options) { + const {scales, chartArea} = chart; + const {xScaleID, yScaleID, xMin, xMax, yMin, yMax} = options; + const xScale = scales[xScaleID]; + const yScale = scales[yScaleID]; + const {top, left, bottom, right} = chartArea; + const streaming = element.$streaming = {}; + if (xScale) { + const min = scaleValue(xScale, xMin, left); + const max = scaleValue(xScale, xMax, right); + const reverse = min.value > max.value; + if (min.transitionable) { + streaming[reverse ? 'x2' : 'x'] = {axisId: xScaleID}; + } + if (max.transitionable) { + streaming[reverse ? 'x' : 'x2'] = {axisId: xScaleID}; + } + if (min.transitionable !== max.transitionable) { + streaming.width = {axisId: xScaleID, reverse: min.transitionable}; + } + } + if (yScale) { + const min = scaleValue(yScale, yMin, top); + const max = scaleValue(yScale, yMax, bottom); + const reverse = min.value > max.value; + if (min.transitionable) { + streaming[reverse ? 'y2' : 'y'] = {axisId: yScaleID}; + } + if (max.transitionable) { + streaming[reverse ? 'y' : 'y2'] = {axisId: yScaleID}; + } + if (min.transitionable !== max.transitionable) { + streaming.height = {axisId: yScaleID, reverse: min.transitionable}; + } + } +} +function updateLineAnnotation(element, chart, options) { + const {scales, chartArea} = chart; + const {scaleID, value} = options; + const scale = scales[scaleID]; + const {top, left, bottom, right} = chartArea; + const streaming = element.$streaming = {}; + if (scale) { + const isHorizontal = scale.isHorizontal(); + const pixel = scaleValue(scale, value); + if (pixel.transitionable) { + streaming[isHorizontal ? 'x' : 'y'] = {axisId: scaleID}; + streaming[isHorizontal ? 'x2' : 'y2'] = {axisId: scaleID}; + } + return isHorizontal ? {top, bottom} : {left, right}; + } + const {xScaleID, yScaleID, xMin, xMax, yMin, yMax} = options; + const xScale = scales[xScaleID]; + const yScale = scales[yScaleID]; + const clip = {}; + if (xScale) { + const min = scaleValue(xScale, xMin); + const max = scaleValue(xScale, xMax); + if (min.transitionable) { + streaming.x = {axisId: xScaleID}; + } else { + clip.left = left; + } + if (max.transitionable) { + streaming.x2 = {axisId: xScaleID}; + } else { + clip.right = right; + } + } + if (yScale) { + const min = scaleValue(yScale, yMin); + const max = scaleValue(yScale, yMax); + if (min.transitionable) { + streaming.y = {axisId: yScaleID}; + } else { + clip.top = top; + } + if (max.transitionable) { + streaming.y2 = {axisId: yScaleID}; + } else { + clip.bottom = bottom; + } + } + return clip; +} +function updatePointAnnotation(element, chart, options) { + const scales = chart.scales; + const {xScaleID, yScaleID, xValue, yValue} = options; + const xScale = scales[xScaleID]; + const yScale = scales[yScaleID]; + const streaming = element.$streaming = {}; + if (xScale) { + const x = scaleValue(xScale, xValue); + if (x.transitionable) { + streaming.x = {axisId: xScaleID}; + } + } + if (yScale) { + const y = scaleValue(yScale, yValue); + if (y.transitionable) { + streaming.y = {axisId: yScaleID}; + } + } +} +function initAnnotationPlugin() { + const BoxAnnotation = chart_js.registry.getElement('boxAnnotation'); + const LineAnnotation = chart_js.registry.getElement('lineAnnotation'); + const PointAnnotation = chart_js.registry.getElement('pointAnnotation'); + const resolveBoxAnnotationProperties = BoxAnnotation.prototype.resolveElementProperties; + const resolveLineAnnotationProperties = LineAnnotation.prototype.resolveElementProperties; + const resolvePointAnnotationProperties = PointAnnotation.prototype.resolveElementProperties; + BoxAnnotation.prototype.resolveElementProperties = function(chart, options) { + updateBoxAnnotation(this, chart, options); + return resolveBoxAnnotationProperties.call(this, chart, options); + }; + LineAnnotation.prototype.resolveElementProperties = function(chart, options) { + const chartArea = chart.chartArea; + chart.chartArea = updateLineAnnotation(this, chart, options); + const properties = resolveLineAnnotationProperties.call(this, chart, options); + chart.chartArea = chartArea; + return properties; + }; + PointAnnotation.prototype.resolveElementProperties = function(chart, options) { + updatePointAnnotation(this, chart, options); + return resolvePointAnnotationProperties.call(this, chart, options); + }; +} +function attachChart$1(plugin, chart) { + const streaming = chart.$streaming; + if (streaming.annotationPlugin !== plugin) { + const afterUpdate = plugin.afterUpdate; + initAnnotationPlugin(); + streaming.annotationPlugin = plugin; + plugin.afterUpdate = (_chart, args, options) => { + const mode = args.mode; + const animationOpts = options.animation; + if (mode === 'quiet') { + options.animation = false; + } + afterUpdate.call(this, _chart, args, options); + if (mode === 'quiet') { + options.animation = animationOpts; + } + }; + } +} +function getElements(chart) { + const plugin = chart.$streaming.annotationPlugin; + if (plugin) { + const state = plugin._getState(chart); + return state && state.elements || []; + } + return []; +} +function detachChart$1(chart) { + delete chart.$streaming.annotationPlugin; +} + +const transitionKeys$1 = {x: ['x', 'caretX'], y: ['y', 'caretY']}; +function update$1(...args) { + const me = this; + const element = me.getActiveElements()[0]; + if (element) { + const meta = me._chart.getDatasetMeta(element.datasetIndex); + me.$streaming = getAxisMap(me, transitionKeys$1, meta); + } else { + me.$streaming = {}; + } + me.constructor.prototype.update.call(me, ...args); +} + +const chartStates = new WeakMap(); +function getState(chart) { + let state = chartStates.get(chart); + if (!state) { + state = {originalScaleOptions: {}}; + chartStates.set(chart, state); + } + return state; +} +function removeState(chart) { + chartStates.delete(chart); +} +function storeOriginalScaleOptions(chart) { + const {originalScaleOptions} = getState(chart); + const scales = chart.scales; + helpers.each(scales, scale => { + const id = scale.id; + if (!originalScaleOptions[id]) { + originalScaleOptions[id] = { + duration: resolveOption(scale, 'duration'), + delay: resolveOption(scale, 'delay') + }; + } + }); + helpers.each(originalScaleOptions, (opt, key) => { + if (!scales[key]) { + delete originalScaleOptions[key]; + } + }); + return originalScaleOptions; +} +function zoomRealTimeScale(scale, zoom, center, limits) { + const {chart, axis} = scale; + const {minDuration = 0, maxDuration = Infinity, minDelay = -Infinity, maxDelay = Infinity} = limits && limits[axis] || {}; + const realtimeOpts = scale.options.realtime; + const duration = resolveOption(scale, 'duration'); + const delay = resolveOption(scale, 'delay'); + const newDuration = clamp(duration * (2 - zoom), minDuration, maxDuration); + let maxPercent, newDelay; + storeOriginalScaleOptions(chart); + if (scale.isHorizontal()) { + maxPercent = (scale.right - center.x) / (scale.right - scale.left); + } else { + maxPercent = (scale.bottom - center.y) / (scale.bottom - scale.top); + } + newDelay = delay + maxPercent * (duration - newDuration); + realtimeOpts.duration = newDuration; + realtimeOpts.delay = clamp(newDelay, minDelay, maxDelay); + return newDuration !== scale.max - scale.min; +} +function panRealTimeScale(scale, delta, limits) { + const {chart, axis} = scale; + const {minDelay = -Infinity, maxDelay = Infinity} = limits && limits[axis] || {}; + const delay = resolveOption(scale, 'delay'); + const newDelay = delay + (scale.getValueForPixel(delta) - scale.getValueForPixel(0)); + storeOriginalScaleOptions(chart); + scale.options.realtime.delay = clamp(newDelay, minDelay, maxDelay); + return true; +} +function resetRealTimeScaleOptions(chart) { + const originalScaleOptions = storeOriginalScaleOptions(chart); + helpers.each(chart.scales, scale => { + const realtimeOptions = scale.options.realtime; + if (realtimeOptions) { + const original = originalScaleOptions[scale.id]; + if (original) { + realtimeOptions.duration = original.duration; + realtimeOptions.delay = original.delay; + } else { + delete realtimeOptions.duration; + delete realtimeOptions.delay; + } + } + }); +} + +function setRealtimeScale(scale, {min, max}, limits, zoom = false) { + const {chart, axis} = scale; + const currentMax = scale.max; + const {minDelay = -Infinity, maxDelay = Infinity} = limits && limits[axis] || {}; + const {minDuration = -Infinity, maxDuration = Infinity} = limits && limits[axis] || {}; + const currentDelay = resolveOption(scale, 'delay'); + storeOriginalScaleOptions(chart); + scale.options.realtime.delay = clamp((currentMax - max) + currentDelay, minDelay, maxDelay); + scale.options.realtime.duration = clamp(max-min, minDuration, maxDuration); + return true; +} + +function initZoomPlugin(plugin) { + plugin.zoomFunctions.realtime = zoomRealTimeScale; + plugin.panFunctions.realtime = panRealTimeScale; + plugin.updateRangeFunctions.realtime = setRealtimeScale; +} +function attachChart(plugin, chart) { + const streaming = chart.$streaming; + if (streaming.zoomPlugin !== plugin) { + const resetZoom = streaming.resetZoom = chart.resetZoom; + initZoomPlugin(plugin); + chart.resetZoom = transition => { + resetRealTimeScaleOptions(chart); + resetZoom(transition); + }; + streaming.zoomPlugin = plugin; + } +} +function detachChart(chart) { + const streaming = chart.$streaming; + if (streaming.zoomPlugin) { + chart.resetZoom = streaming.resetZoom; + removeState(chart); + delete streaming.resetZoom; + delete streaming.zoomPlugin; + } +} + +const INTERVALS = { + millisecond: { + common: true, + size: 1, + steps: [1, 2, 5, 10, 20, 50, 100, 250, 500] + }, + second: { + common: true, + size: 1000, + steps: [1, 2, 5, 10, 15, 30] + }, + minute: { + common: true, + size: 60000, + steps: [1, 2, 5, 10, 15, 30] + }, + hour: { + common: true, + size: 3600000, + steps: [1, 2, 3, 6, 12] + }, + day: { + common: true, + size: 86400000, + steps: [1, 2, 5] + }, + week: { + common: false, + size: 604800000, + steps: [1, 2, 3, 4] + }, + month: { + common: true, + size: 2.628e9, + steps: [1, 2, 3] + }, + quarter: { + common: false, + size: 7.884e9, + steps: [1, 2, 3, 4] + }, + year: { + common: true, + size: 3.154e10 + } +}; +const UNITS = Object.keys(INTERVALS); +function determineStepSize(min, max, unit, capacity) { + const range = max - min; + const {size: milliseconds, steps} = INTERVALS[unit]; + let factor; + if (!steps) { + return Math.ceil(range / (capacity * milliseconds)); + } + for (let i = 0, ilen = steps.length; i < ilen; ++i) { + factor = steps[i]; + if (Math.ceil(range / (milliseconds * factor)) <= capacity) { + break; + } + } + return factor; +} +function determineUnitForAutoTicks(minUnit, min, max, capacity) { + const range = max - min; + const ilen = UNITS.length; + for (let i = UNITS.indexOf(minUnit); i < ilen - 1; ++i) { + const {common, size, steps} = INTERVALS[UNITS[i]]; + const factor = steps ? steps[steps.length - 1] : Number.MAX_SAFE_INTEGER; + if (common && Math.ceil(range / (factor * size)) <= capacity) { + return UNITS[i]; + } + } + return UNITS[ilen - 1]; +} +function determineMajorUnit(unit) { + for (let i = UNITS.indexOf(unit) + 1, ilen = UNITS.length; i < ilen; ++i) { + if (INTERVALS[UNITS[i]].common) { + return UNITS[i]; + } + } +} +function addTick(ticks, time, timestamps) { + if (!timestamps) { + ticks[time] = true; + } else if (timestamps.length) { + const {lo, hi} = helpers._lookup(timestamps, time); + const timestamp = timestamps[lo] >= time ? timestamps[lo] : timestamps[hi]; + ticks[timestamp] = true; + } +} +const datasetPropertyKeys = [ + 'pointBackgroundColor', + 'pointBorderColor', + 'pointBorderWidth', + 'pointRadius', + 'pointRotation', + 'pointStyle', + 'pointHitRadius', + 'pointHoverBackgroundColor', + 'pointHoverBorderColor', + 'pointHoverBorderWidth', + 'pointHoverRadius', + 'backgroundColor', + 'borderColor', + 'borderSkipped', + 'borderWidth', + 'hoverBackgroundColor', + 'hoverBorderColor', + 'hoverBorderWidth', + 'hoverRadius', + 'hitRadius', + 'radius', + 'rotation' +]; +function clean(scale) { + const {chart, id, max} = scale; + const duration = resolveOption(scale, 'duration'); + const delay = resolveOption(scale, 'delay'); + const ttl = resolveOption(scale, 'ttl'); + const pause = resolveOption(scale, 'pause'); + const min = Date.now() - (isNaN(ttl) ? duration + delay : ttl); + let i, start, count, removalRange; + helpers.each(chart.data.datasets, (dataset, datasetIndex) => { + const meta = chart.getDatasetMeta(datasetIndex); + const axis = id === meta.xAxisID && 'x' || id === meta.yAxisID && 'y'; + if (axis) { + const controller = meta.controller; + const data = dataset.data; + const length = data.length; + if (pause) { + for (i = 0; i < length; ++i) { + const point = controller.getParsed(i); + if (point && !(point[axis] < max)) { + break; + } + } + start = i + 2; + } else { + start = 0; + } + for (i = start; i < length; ++i) { + const point = controller.getParsed(i); + if (!point || !(point[axis] <= min)) { + break; + } + } + count = i - start; + if (isNaN(ttl)) { + count = Math.max(count - 2, 0); + } + data.splice(start, count); + helpers.each(datasetPropertyKeys, key => { + if (helpers.isArray(dataset[key])) { + dataset[key].splice(start, count); + } + }); + helpers.each(dataset.datalabels, value => { + if (helpers.isArray(value)) { + value.splice(start, count); + } + }); + if (typeof data[0] !== 'object') { + removalRange = { + start: start, + count: count + }; + } + helpers.each(chart._active, (item, index) => { + if (item.datasetIndex === datasetIndex && item.index >= start) { + if (item.index >= start + count) { + item.index -= count; + } else { + chart._active.splice(index, 1); + } + } + }, null, true); + } + }); + if (removalRange) { + chart.data.labels.splice(removalRange.start, removalRange.count); + } +} +function transition(element, id, translate) { + const animations = element.$animations || {}; + helpers.each(element.$streaming, (item, key) => { + if (item.axisId === id) { + const delta = item.reverse ? -translate : translate; + const animation = animations[key]; + if (helpers.isFinite(element[key])) { + element[key] -= delta; + } + if (animation) { + animation._from -= delta; + animation._to -= delta; + } + } + }); +} +function scroll(scale) { + const {chart, id, $realtime: realtime} = scale; + const duration = resolveOption(scale, 'duration'); + const delay = resolveOption(scale, 'delay'); + const isHorizontal = scale.isHorizontal(); + const length = isHorizontal ? scale.width : scale.height; + const now = Date.now(); + const tooltip = chart.tooltip; + const annotations = getElements(chart); + let offset = length * (now - realtime.head) / duration; + if (isHorizontal === !!scale.options.reverse) { + offset = -offset; + } + helpers.each(chart.data.datasets, (dataset, datasetIndex) => { + const meta = chart.getDatasetMeta(datasetIndex); + const {data: elements = [], dataset: element} = meta; + for (let i = 0, ilen = elements.length; i < ilen; ++i) { + transition(elements[i], id, offset); + } + if (element) { + transition(element, id, offset); + delete element._path; + } + }); + for (let i = 0, ilen = annotations.length; i < ilen; ++i) { + transition(annotations[i], id, offset); + } + if (tooltip) { + transition(tooltip, id, offset); + } + scale.max = now - delay; + scale.min = scale.max - duration; + realtime.head = now; +} +class RealTimeScale extends chart_js.TimeScale { + constructor(props) { + super(props); + this.$realtime = this.$realtime || {}; + } + init(scaleOpts, opts) { + const me = this; + super.init(scaleOpts, opts); + startDataRefreshTimer(me.$realtime, () => { + const chart = me.chart; + const onRefresh = resolveOption(me, 'onRefresh'); + helpers.callback(onRefresh, [chart], me); + clean(me); + chart.update('quiet'); + return resolveOption(me, 'refresh'); + }); + } + update(maxWidth, maxHeight, margins) { + const me = this; + const {$realtime: realtime, options} = me; + const {bounds, offset, ticks: ticksOpts} = options; + const {autoSkip, source, major: majorTicksOpts} = ticksOpts; + const majorEnabled = majorTicksOpts.enabled; + if (resolveOption(me, 'pause')) { + stopFrameRefreshTimer(realtime); + } else { + if (!realtime.frameRequestID) { + realtime.head = Date.now(); + } + startFrameRefreshTimer(realtime, () => { + const chart = me.chart; + const streaming = chart.$streaming; + scroll(me); + if (streaming) { + helpers.callback(streaming.render, [chart]); + } + return resolveOption(me, 'frameRate'); + }); + } + options.bounds = undefined; + options.offset = false; + ticksOpts.autoSkip = false; + ticksOpts.source = source === 'auto' ? '' : source; + majorTicksOpts.enabled = true; + super.update(maxWidth, maxHeight, margins); + options.bounds = bounds; + options.offset = offset; + ticksOpts.autoSkip = autoSkip; + ticksOpts.source = source; + majorTicksOpts.enabled = majorEnabled; + } + buildTicks() { + const me = this; + const duration = resolveOption(me, 'duration'); + const delay = resolveOption(me, 'delay'); + const max = me.$realtime.head - delay; + const min = max - duration; + const maxArray = [1e15, max]; + const minArray = [-1e15, min]; + Object.defineProperty(me, 'min', { + get: () => minArray.shift(), + set: helpers.noop + }); + Object.defineProperty(me, 'max', { + get: () => maxArray.shift(), + set: helpers.noop + }); + const ticks = super.buildTicks(); + delete me.min; + delete me.max; + me.min = min; + me.max = max; + return ticks; + } + calculateLabelRotation() { + const ticksOpts = this.options.ticks; + const maxRotation = ticksOpts.maxRotation; + ticksOpts.maxRotation = ticksOpts.minRotation || 0; + super.calculateLabelRotation(); + ticksOpts.maxRotation = maxRotation; + } + fit() { + const me = this; + const options = me.options; + super.fit(); + if (options.ticks.display && options.display && me.isHorizontal()) { + me.paddingLeft = 3; + me.paddingRight = 3; + me._handleMargins(); + } + } + draw(chartArea) { + const me = this; + const {chart, ctx} = me; + const area = me.isHorizontal() ? + { + left: chartArea.left, + top: 0, + right: chartArea.right, + bottom: chart.height + } : { + left: 0, + top: chartArea.top, + right: chart.width, + bottom: chartArea.bottom + }; + me._gridLineItems = null; + me._labelItems = null; + helpers.clipArea(ctx, area); + super.draw(chartArea); + helpers.unclipArea(ctx); + } + destroy() { + const realtime = this.$realtime; + stopFrameRefreshTimer(realtime); + stopDataRefreshTimer(realtime); + } + _generate() { + const me = this; + const adapter = me._adapter; + const duration = resolveOption(me, 'duration'); + const delay = resolveOption(me, 'delay'); + const refresh = resolveOption(me, 'refresh'); + const max = me.$realtime.head - delay; + const min = max - duration; + const capacity = me._getLabelCapacity(min); + const {time: timeOpts, ticks: ticksOpts} = me.options; + const minor = timeOpts.unit || determineUnitForAutoTicks(timeOpts.minUnit, min, max, capacity); + const major = determineMajorUnit(minor); + const stepSize = timeOpts.stepSize || determineStepSize(min, max, minor, capacity); + const weekday = minor === 'week' ? timeOpts.isoWeekday : false; + const majorTicksEnabled = ticksOpts.major.enabled; + const hasWeekday = helpers.isNumber(weekday) || weekday === true; + const interval = INTERVALS[minor]; + const ticks = {}; + let first = min; + let time, count; + if (hasWeekday) { + first = +adapter.startOf(first, 'isoWeek', weekday); + } + first = +adapter.startOf(first, hasWeekday ? 'day' : minor); + if (adapter.diff(max, min, minor) > 100000 * stepSize) { + throw new Error(min + ' and ' + max + ' are too far apart with stepSize of ' + stepSize + ' ' + minor); + } + time = first; + if (majorTicksEnabled && major && !hasWeekday && !timeOpts.round) { + time = +adapter.startOf(time, major); + time = +adapter.add(time, ~~((first - time) / (interval.size * stepSize)) * stepSize, minor); + } + const timestamps = ticksOpts.source === 'data' && me.getDataTimestamps(); + for (count = 0; time < max + refresh; time = +adapter.add(time, stepSize, minor), count++) { + addTick(ticks, time, timestamps); + } + if (time === max + refresh || count === 1) { + addTick(ticks, time, timestamps); + } + return Object.keys(ticks).sort((a, b) => a - b).map(x => +x); + } +} +RealTimeScale.id = 'realtime'; +RealTimeScale.defaults = { + bounds: 'data', + adapters: {}, + time: { + parser: false, + unit: false, + round: false, + isoWeekday: false, + minUnit: 'millisecond', + displayFormats: {} + }, + realtime: {}, + ticks: { + autoSkip: false, + source: 'auto', + major: { + enabled: true + } + } +}; +chart_js.defaults.describe('scale.realtime', { + _scriptable: name => name !== 'onRefresh' +}); + +var version = "2.0.0"; + +chart_js.defaults.set('transitions', { + quiet: { + animation: { + duration: 0 + } + } +}); +const transitionKeys = {x: ['x', 'cp1x', 'cp2x'], y: ['y', 'cp1y', 'cp2y']}; +function update(mode) { + const me = this; + if (mode === 'quiet') { + helpers.each(me.data.datasets, (dataset, datasetIndex) => { + const controller = me.getDatasetMeta(datasetIndex).controller; + controller._setStyle = function(element, index, _mode, active) { + chart_js.DatasetController.prototype._setStyle.call(this, element, index, 'quiet', active); + }; + }); + } + chart_js.Chart.prototype.update.call(me, mode); + if (mode === 'quiet') { + helpers.each(me.data.datasets, (dataset, datasetIndex) => { + delete me.getDatasetMeta(datasetIndex).controller._setStyle; + }); + } +} +function render(chart) { + const streaming = chart.$streaming; + chart.render(); + if (streaming.lastMouseEvent) { + setTimeout(() => { + const lastMouseEvent = streaming.lastMouseEvent; + if (lastMouseEvent) { + chart._eventHandler(lastMouseEvent); + } + }, 0); + } +} +var StreamingPlugin = { + id: 'streaming', + version, + beforeInit(chart) { + const streaming = chart.$streaming = chart.$streaming || {render}; + const canvas = streaming.canvas = chart.canvas; + const mouseEventListener = streaming.mouseEventListener = event => { + const pos = helpers.getRelativePosition(event, chart); + streaming.lastMouseEvent = { + type: 'mousemove', + chart: chart, + native: event, + x: pos.x, + y: pos.y + }; + }; + canvas.addEventListener('mousedown', mouseEventListener); + canvas.addEventListener('mouseup', mouseEventListener); + }, + afterInit(chart) { + chart.update = update; + }, + beforeUpdate(chart) { + const {scales, elements} = chart.options; + const tooltip = chart.tooltip; + helpers.each(scales, ({type}) => { + if (type === 'realtime') { + elements.line.capBezierPoints = false; + } + }); + if (tooltip) { + tooltip.update = update$1; + } + try { + const plugin = chart_js.registry.getPlugin('annotation'); + attachChart$1(plugin, chart); + } catch (e) { + detachChart$1(chart); + } + try { + const plugin = chart_js.registry.getPlugin('zoom'); + attachChart(plugin, chart); + } catch (e) { + detachChart(chart); + } + }, + beforeDatasetUpdate(chart, args) { + const {meta, mode} = args; + if (mode === 'quiet') { + const {controller, $animations} = meta; + if ($animations && $animations.visible && $animations.visible._active) { + controller.updateElement = helpers.noop; + controller.updateSharedOptions = helpers.noop; + } + } + }, + afterDatasetUpdate(chart, args) { + const {meta, mode} = args; + const {data: elements = [], dataset: element, controller} = meta; + for (let i = 0, ilen = elements.length; i < ilen; ++i) { + elements[i].$streaming = getAxisMap(elements[i], transitionKeys, meta); + } + if (element) { + element.$streaming = getAxisMap(element, transitionKeys, meta); + } + if (mode === 'quiet') { + delete controller.updateElement; + delete controller.updateSharedOptions; + } + }, + beforeDatasetDraw(chart, args) { + const {ctx, chartArea, width, height} = chart; + const {xAxisID, yAxisID, controller} = args.meta; + const area = { + left: 0, + top: 0, + right: width, + bottom: height + }; + if (xAxisID && controller.getScaleForId(xAxisID) instanceof RealTimeScale) { + area.left = chartArea.left; + area.right = chartArea.right; + } + if (yAxisID && controller.getScaleForId(yAxisID) instanceof RealTimeScale) { + area.top = chartArea.top; + area.bottom = chartArea.bottom; + } + helpers.clipArea(ctx, area); + }, + afterDatasetDraw(chart) { + helpers.unclipArea(chart.ctx); + }, + beforeEvent(chart, args) { + const streaming = chart.$streaming; + const event = args.event; + if (event.type === 'mousemove') { + streaming.lastMouseEvent = event; + } else if (event.type === 'mouseout') { + delete streaming.lastMouseEvent; + } + }, + destroy(chart) { + const {scales, $streaming: streaming, tooltip} = chart; + const {canvas, mouseEventListener} = streaming; + delete chart.update; + if (tooltip) { + delete tooltip.update; + } + canvas.removeEventListener('mousedown', mouseEventListener); + canvas.removeEventListener('mouseup', mouseEventListener); + helpers.each(scales, scale => { + if (scale instanceof RealTimeScale) { + scale.destroy(); + } + }); + }, + defaults: { + duration: 10000, + delay: 0, + frameRate: 30, + refresh: 1000, + onRefresh: null, + pause: false, + ttl: undefined + }, + descriptors: { + _scriptable: name => name !== 'onRefresh' + } +}; + +const registerables = [StreamingPlugin, RealTimeScale]; +chart_js.Chart.register(registerables); + +return registerables; + +}))); diff --git a/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-zoom.js b/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-zoom.js new file mode 100644 index 00000000..e497c7c5 --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/modified-vendor/chartjs-plugin-zoom.js @@ -0,0 +1,954 @@ +/** + * NOTE: this file was modified by M Starch from its original version. This added following: + * + * - doUpdateRange function following the pattern of doZoom to allow for plugins to modify updateRange behavior as + * in line with doZoom's dispatch to plugins. + */ + +/*! +* chartjs-plugin-zoom v1.1.1 +* undefined + * (c) 2016-2021 chartjs-plugin-zoom Contributors + * Released under the MIT License + */ +(function (global, factory) { +typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('chart.js'), require('hammerjs'), require('chart.js/helpers')) : +typeof define === 'function' && define.amd ? define(['chart.js', 'hammerjs', 'chart.js/helpers'], factory) : +(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.ChartZoom = factory(global.Chart, global.Hammer, global.Chart.helpers)); +}(this, (function (chart_js, Hammer, helpers) { 'use strict'; + +function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } + +var Hammer__default = /*#__PURE__*/_interopDefaultLegacy(Hammer); + +const getModifierKey = opts => opts && opts.enabled && opts.modifierKey; +const keyPressed = (key, event) => key && event[key + 'Key']; +const keyNotPressed = (key, event) => key && !event[key + 'Key']; + +/** + * @param {string|function} mode can be 'x', 'y' or 'xy' + * @param {string} dir can be 'x' or 'y' + * @param {import('chart.js').Chart} chart instance of the chart in question + * @returns {boolean} + */ +function directionEnabled(mode, dir, chart) { + if (mode === undefined) { + return true; + } else if (typeof mode === 'string') { + return mode.indexOf(dir) !== -1; + } else if (typeof mode === 'function') { + return mode({chart}).indexOf(dir) !== -1; + } + + return false; +} + +/** + * Debounces calling `fn` for `delay` ms + * @param {function} fn - Function to call. No arguments are passed. + * @param {number} delay - Delay in ms. 0 = immediate invocation. + * @returns {function} + */ +function debounce(fn, delay) { + let timeout; + return function() { + clearTimeout(timeout); + timeout = setTimeout(fn, delay); + return delay; + }; +} + +/** This function use for check what axis now under mouse cursor. + * @param {{x: number, y: number}} point - the mouse location + * @param {import('chart.js').Chart} [chart] instance of the chart in question + * @return {import('chart.js').Scale} + */ +function getScaleUnderPoint({x, y}, chart) { + const scales = chart.scales; + const scaleIds = Object.keys(scales); + for (let i = 0; i < scaleIds.length; i++) { + const scale = scales[scaleIds[i]]; + if (y >= scale.top && y <= scale.bottom && x >= scale.left && x <= scale.right) { + return scale; + } + } + return null; +} + +/** This function return only one scale whose position is under mouse cursor and which direction is enabled. + * If under mouse hasn't scale, then return all other scales which 'mode' is diffrent with overScaleMode. + * So 'overScaleMode' works as a limiter to scale the user-selected scale (in 'mode') only when the cursor is under the scale, + * and other directions in 'mode' works as before. + * Example: mode = 'xy', overScaleMode = 'y' -> it's means 'x' - works as before, and 'y' only works for one scale when cursor is under it. + * options.overScaleMode can be a function if user want zoom only one scale of many for example. + * @param {string} mode - 'xy', 'x' or 'y' + * @param {{x: number, y: number}} point - the mouse location + * @param {import('chart.js').Chart} [chart] instance of the chart in question + * @return {import('chart.js').Scale[]} + */ +function getEnabledScalesByPoint(mode, point, chart) { + const scale = getScaleUnderPoint(point, chart); + + if (scale && directionEnabled(mode, scale.axis, chart)) { + return [scale]; + } + + const enabledScales = []; + helpers.each(chart.scales, function(scaleItem) { + if (!directionEnabled(mode, scaleItem.axis, chart)) { + enabledScales.push(scaleItem); + } + }); + return enabledScales; +} + +const chartStates = new WeakMap(); + +function getState(chart) { + let state = chartStates.get(chart); + if (!state) { + state = { + originalScaleLimits: {}, + updatedScaleLimits: {}, + handlers: {}, + panDelta: {} + }; + chartStates.set(chart, state); + } + return state; +} + +function removeState(chart) { + chartStates.delete(chart); +} + +function zoomDelta(scale, zoom, center) { + const range = scale.max - scale.min; + const newRange = range * (zoom - 1); + + const centerPoint = scale.isHorizontal() ? center.x : center.y; + const minPercent = (scale.getValueForPixel(centerPoint) - scale.min) / range || 0; + const maxPercent = 1 - minPercent; + + return { + min: newRange * minPercent, + max: newRange * maxPercent + }; +} + +function getLimit(state, scale, scaleLimits, prop, fallback) { + let limit = scaleLimits[prop]; + if (limit === 'original') { + const original = state.originalScaleLimits[scale.id][prop]; + limit = helpers.valueOrDefault(original.options, original.scale); + } + return helpers.valueOrDefault(limit, fallback); +} + +function updateRange(scale, {min, max}, limits, zoom = false) { + const state = getState(scale.chart); + const {id, axis, options: scaleOpts} = scale; + + const scaleLimits = limits && (limits[id] || limits[axis]) || {}; + const {minRange = 0} = scaleLimits; + const minLimit = getLimit(state, scale, scaleLimits, 'min', -Infinity); + const maxLimit = getLimit(state, scale, scaleLimits, 'max', Infinity); + + const cmin = Math.max(min, minLimit); + const cmax = Math.min(max, maxLimit); + const range = zoom ? Math.max(cmax - cmin, minRange) : scale.max - scale.min; + if (cmax - cmin !== range) { + if (minLimit > cmax - range) { + min = cmin; + max = cmin + range; + } else if (maxLimit < cmin + range) { + max = cmax; + min = cmax - range; + } else { + const offset = (range - cmax + cmin) / 2; + min = cmin - offset; + max = cmax + offset; + } + } else { + min = cmin; + max = cmax; + } + scaleOpts.min = min; + scaleOpts.max = max; + + state.updatedScaleLimits[scale.id] = {min, max}; + + // return true if the scale range is changed + return scale.parse(min) !== scale.min || scale.parse(max) !== scale.max; +} + +function zoomNumericalScale(scale, zoom, center, limits) { + const delta = zoomDelta(scale, zoom, center); + const newRange = {min: scale.min + delta.min, max: scale.max - delta.max}; + return doUpdateRange(scale, newRange, limits, true); +} + +const integerChange = (v) => v === 0 || isNaN(v) ? 0 : v < 0 ? Math.min(Math.round(v), -1) : Math.max(Math.round(v), 1); + +function existCategoryFromMaxZoom(scale) { + const labels = scale.getLabels(); + const maxIndex = labels.length - 1; + + if (scale.min > 0) { + scale.min -= 1; + } + if (scale.max < maxIndex) { + scale.max += 1; + } +} + +function zoomCategoryScale(scale, zoom, center, limits) { + const delta = zoomDelta(scale, zoom, center); + if (scale.min === scale.max && zoom < 1) { + existCategoryFromMaxZoom(scale); + } + const newRange = {min: scale.min + integerChange(delta.min), max: scale.max - integerChange(delta.max)}; + return doUpdateRange(scale, newRange, limits, true); +} + +function scaleLength(scale) { + return scale.isHorizontal() ? scale.width : scale.height; +} + +function panCategoryScale(scale, delta, limits) { + const labels = scale.getLabels(); + const lastLabelIndex = labels.length - 1; + let {min, max} = scale; + // The visible range. Ticks can be skipped, and thus not reliable. + const range = Math.max(max - min, 1); + // How many pixels of delta is required before making a step. stepSize, but limited to max 1/10 of the scale length. + const stepDelta = Math.round(scaleLength(scale) / Math.max(range, 10)); + const stepSize = Math.round(Math.abs(delta / stepDelta)); + let applied; + if (delta < -stepDelta) { + max = Math.min(max + stepSize, lastLabelIndex); + min = range === 1 ? max : max - range; + applied = max === lastLabelIndex; + } else if (delta > stepDelta) { + min = Math.max(0, min - stepSize); + max = range === 1 ? min : min + range; + applied = min === 0; + } + + return doUpdateRange(scale, {min, max}, limits) || applied; +} + +const OFFSETS = { + second: 500, // 500 ms + minute: 30 * 1000, // 30 s + hour: 30 * 60 * 1000, // 30 m + day: 12 * 60 * 60 * 1000, // 12 h + week: 3.5 * 24 * 60 * 60 * 1000, // 3.5 d + month: 15 * 24 * 60 * 60 * 1000, // 15 d + quarter: 60 * 24 * 60 * 60 * 1000, // 60 d + year: 182 * 24 * 60 * 60 * 1000 // 182 d +}; + +function panNumericalScale(scale, delta, limits, canZoom = false) { + const {min: prevStart, max: prevEnd, options} = scale; + const round = options.time && options.time.round; + const offset = OFFSETS[round] || 0; + const newMin = scale.getValueForPixel(scale.getPixelForValue(prevStart + offset) - delta); + const newMax = scale.getValueForPixel(scale.getPixelForValue(prevEnd + offset) - delta); + const {min: minLimit = -Infinity, max: maxLimit = Infinity} = canZoom && limits && limits[scale.axis] || {}; + if (isNaN(newMin) || isNaN(newMax) || newMin < minLimit || newMax > maxLimit) { + // At limit: No change but return true to indicate no need to store the delta. + // NaN can happen for 0-dimension scales (either because they were configured + // with min === max or because the chart has 0 plottable area). + return true; + } + return doUpdateRange(scale, {min: newMin, max: newMax}, limits, canZoom); +} + +function panNonLinearScale(scale, delta, limits) { + return panNumericalScale(scale, delta, limits, true); +} + +const zoomFunctions = { + category: zoomCategoryScale, + default: zoomNumericalScale, +}; + +const panFunctions = { + category: panCategoryScale, + default: panNumericalScale, + logarithmic: panNonLinearScale, + timeseries: panNonLinearScale, +}; + +const updateRangeFunctions = { + default: updateRange +}; + +function shouldUpdateScaleLimits(scale, originalScaleLimits, updatedScaleLimits) { + const {id, options: {min, max}} = scale; + if (!originalScaleLimits[id] || !updatedScaleLimits[id]) { + return true; + } + const previous = updatedScaleLimits[id]; + return previous.min !== min || previous.max !== max; +} + +function removeMissingScales(limits, scales) { + helpers.each(limits, (opt, key) => { + if (!scales[key]) { + delete limits[key]; + } + }); +} + +function storeOriginalScaleLimits(chart, state) { + const {scales} = chart; + const {originalScaleLimits, updatedScaleLimits} = state; + + helpers.each(scales, function(scale) { + if (shouldUpdateScaleLimits(scale, originalScaleLimits, updatedScaleLimits)) { + originalScaleLimits[scale.id] = { + min: {scale: scale.min, options: scale.options.min}, + max: {scale: scale.max, options: scale.options.max}, + }; + } + }); + + removeMissingScales(originalScaleLimits, scales); + removeMissingScales(updatedScaleLimits, scales); + return originalScaleLimits; +} + +function doZoom(scale, amount, center, limits) { + const fn = zoomFunctions[scale.type] || zoomFunctions.default; + helpers.callback(fn, [scale, amount, center, limits]); +} + +function doUpdateRange(scale, range, limits, zoom = false) { + const fn = updateRangeFunctions[scale.type] || updateRangeFunctions.default; + helpers.callback(fn, [scale, range, limits, zoom]); +} + +function getCenter(chart) { + const ca = chart.chartArea; + return { + x: (ca.left + ca.right) / 2, + y: (ca.top + ca.bottom) / 2, + }; +} + +/** + * @param chart The chart instance + * @param {number | {x?: number, y?: number, focalPoint?: {x: number, y: number}}} amount The zoom percentage or percentages and focal point + * @param {string} [transition] Which transition mode to use. Defaults to 'none' + */ +function zoom(chart, amount, transition = 'none') { + const {x = 1, y = 1, focalPoint = getCenter(chart)} = typeof amount === 'number' ? {x: amount, y: amount} : amount; + const state = getState(chart); + const {options: {limits, zoom: zoomOptions}} = state; + const {mode = 'xy', overScaleMode} = zoomOptions || {}; + + storeOriginalScaleLimits(chart, state); + + const xEnabled = x !== 1 && directionEnabled(mode, 'x', chart); + const yEnabled = y !== 1 && directionEnabled(mode, 'y', chart); + const enabledScales = overScaleMode && getEnabledScalesByPoint(overScaleMode, focalPoint, chart); + + helpers.each(enabledScales || chart.scales, function(scale) { + if (scale.isHorizontal() && xEnabled) { + doZoom(scale, x, focalPoint, limits); + } else if (!scale.isHorizontal() && yEnabled) { + doZoom(scale, y, focalPoint, limits); + } + }); + + chart.update(transition); + + helpers.callback(zoomOptions.onZoom, [{chart}]); +} + +function getRange(scale, pixel0, pixel1) { + const v0 = scale.getValueForPixel(pixel0); + const v1 = scale.getValueForPixel(pixel1); + return { + min: Math.min(v0, v1), + max: Math.max(v0, v1) + }; +} + +function zoomRect(chart, p0, p1, transition = 'none') { + const state = getState(chart); + const {options: {limits, zoom: zoomOptions}} = state; + const {mode = 'xy'} = zoomOptions; + + storeOriginalScaleLimits(chart, state); + const xEnabled = directionEnabled(mode, 'x', chart); + const yEnabled = directionEnabled(mode, 'y', chart); + + helpers.each(chart.scales, function(scale) { + if (scale.isHorizontal() && xEnabled) { + doUpdateRange(scale, getRange(scale, p0.x, p1.x), limits, true); + } else if (!scale.isHorizontal() && yEnabled) { + doUpdateRange(scale, getRange(scale, p0.y, p1.y), limits, true); + } + }); + + chart.update(transition); + + helpers.callback(zoomOptions.onZoom, [{chart}]); +} + +function zoomScale(chart, scaleId, range, transition = 'none') { + storeOriginalScaleLimits(chart, getState(chart)); + const scale = chart.scales[scaleId]; + doUpdateRange(scale, range, undefined, true); + chart.update(transition); +} + +function resetZoom(chart, transition = 'default') { + const state = getState(chart); + const originalScaleLimits = storeOriginalScaleLimits(chart, state); + + helpers.each(chart.scales, function(scale) { + const scaleOptions = scale.options; + if (originalScaleLimits[scale.id]) { + scaleOptions.min = originalScaleLimits[scale.id].min.options; + scaleOptions.max = originalScaleLimits[scale.id].max.options; + } else { + delete scaleOptions.min; + delete scaleOptions.max; + } + }); + chart.update(transition); + helpers.callback(state.options.zoom.onZoomComplete, [{chart}]); +} + +function getOriginalRange(state, scaleId) { + const original = state.originalScaleLimits[scaleId]; + if (!original) { + return; + } + const {min, max} = original; + return helpers.valueOrDefault(max.options, max.scale) - helpers.valueOrDefault(min.options, min.scale); +} + +function getZoomLevel(chart) { + const state = getState(chart); + let min = 1; + let max = 1; + helpers.each(chart.scales, function(scale) { + const origRange = getOriginalRange(state, scale.id); + if (origRange) { + const level = Math.round(origRange / (scale.max - scale.min) * 100) / 100; + min = Math.min(min, level); + max = Math.max(max, level); + } + }); + return min < 1 ? min : max; +} + +function panScale(scale, delta, limits, state) { + const {panDelta} = state; + // Add possible cumulative delta from previous pan attempts where scale did not change + const storedDelta = panDelta[scale.id] || 0; + if (helpers.sign(storedDelta) === helpers.sign(delta)) { + delta += storedDelta; + } + const fn = panFunctions[scale.type] || panFunctions.default; + if (helpers.callback(fn, [scale, delta, limits])) { + // The scale changed, reset cumulative delta + panDelta[scale.id] = 0; + } else { + // The scale did not change, store cumulative delta + panDelta[scale.id] = delta; + } +} + +function pan(chart, delta, enabledScales, transition = 'none') { + const {x = 0, y = 0} = typeof delta === 'number' ? {x: delta, y: delta} : delta; + const state = getState(chart); + const {options: {pan: panOptions, limits}} = state; + const {mode = 'xy', onPan} = panOptions || {}; + + storeOriginalScaleLimits(chart, state); + + const xEnabled = x !== 0 && directionEnabled(mode, 'x', chart); + const yEnabled = y !== 0 && directionEnabled(mode, 'y', chart); + + helpers.each(enabledScales || chart.scales, function(scale) { + if (scale.isHorizontal() && xEnabled) { + panScale(scale, x, limits, state); + } else if (!scale.isHorizontal() && yEnabled) { + panScale(scale, y, limits, state); + } + }); + + chart.update(transition); + + helpers.callback(onPan, [{chart}]); +} + +function removeHandler(chart, type) { + const {handlers} = getState(chart); + const handler = handlers[type]; + if (handler && handler.target) { + handler.target.removeEventListener(type, handler); + delete handlers[type]; + } +} + +function addHandler(chart, target, type, handler) { + const {handlers, options} = getState(chart); + removeHandler(chart, type); + handlers[type] = (event) => handler(chart, event, options); + handlers[type].target = target; + target.addEventListener(type, handlers[type]); +} + +function mouseMove(chart, event) { + const state = getState(chart); + if (state.dragStart) { + state.dragging = true; + state.dragEnd = event; + chart.update('none'); + } +} + +function zoomStart(chart, event, zoomOptions) { + const {onZoomStart, onZoomRejected} = zoomOptions; + if (onZoomStart) { + const {left: offsetX, top: offsetY} = event.target.getBoundingClientRect(); + const point = { + x: event.clientX - offsetX, + y: event.clientY - offsetY + }; + if (helpers.callback(onZoomStart, [{chart, event, point}]) === false) { + helpers.callback(onZoomRejected, [{chart, event}]); + return false; + } + } +} + +function mouseDown(chart, event) { + const state = getState(chart); + const {pan: panOptions, zoom: zoomOptions = {}} = state.options; + if (keyPressed(getModifierKey(panOptions), event) || keyNotPressed(getModifierKey(zoomOptions.drag), event)) { + return helpers.callback(zoomOptions.onZoomRejected, [{chart, event}]); + } + + if (zoomStart(chart, event, zoomOptions) === false) { + return; + } + state.dragStart = event; + + addHandler(chart, chart.canvas, 'mousemove', mouseMove); +} + +function computeDragRect(chart, mode, beginPoint, endPoint) { + const {left: offsetX, top: offsetY} = beginPoint.target.getBoundingClientRect(); + const xEnabled = directionEnabled(mode, 'x', chart); + const yEnabled = directionEnabled(mode, 'y', chart); + let {top, left, right, bottom, width: chartWidth, height: chartHeight} = chart.chartArea; + + if (xEnabled) { + left = Math.min(beginPoint.clientX, endPoint.clientX) - offsetX; + right = Math.max(beginPoint.clientX, endPoint.clientX) - offsetX; + } + + if (yEnabled) { + top = Math.min(beginPoint.clientY, endPoint.clientY) - offsetY; + bottom = Math.max(beginPoint.clientY, endPoint.clientY) - offsetY; + } + const width = right - left; + const height = bottom - top; + + return { + left, + top, + right, + bottom, + width, + height, + zoomX: xEnabled && width ? 1 + ((chartWidth - width) / chartWidth) : 1, + zoomY: yEnabled && height ? 1 + ((chartHeight - height) / chartHeight) : 1 + }; +} + +function mouseUp(chart, event) { + const state = getState(chart); + if (!state.dragStart) { + return; + } + + removeHandler(chart, 'mousemove'); + const {mode, onZoomComplete, drag: {threshold = 0}} = state.options.zoom; + const rect = computeDragRect(chart, mode, state.dragStart, event); + const distanceX = directionEnabled(mode, 'x', chart) ? rect.width : 0; + const distanceY = directionEnabled(mode, 'y', chart) ? rect.height : 0; + const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); + + // Remove drag start and end before chart update to stop drawing selected area + state.dragStart = state.dragEnd = null; + + if (distance <= threshold) { + state.dragging = false; + chart.update('none'); + return; + } + + zoomRect(chart, {x: rect.left, y: rect.top}, {x: rect.right, y: rect.bottom}, 'zoom'); + + setTimeout(() => (state.dragging = false), 500); + helpers.callback(onZoomComplete, [{chart}]); +} + +function wheelPreconditions(chart, event, zoomOptions) { + // Before preventDefault, check if the modifier key required and pressed + if (keyNotPressed(getModifierKey(zoomOptions.wheel), event)) { + helpers.callback(zoomOptions.onZoomRejected, [{chart, event}]); + return; + } + + if (zoomStart(chart, event, zoomOptions) === false) { + return; + } + + // Prevent the event from triggering the default behavior (eg. Content scrolling). + if (event.cancelable) { + event.preventDefault(); + } + + // Firefox always fires the wheel event twice: + // First without the delta and right after that once with the delta properties. + if (event.deltaY === undefined) { + return; + } + return true; +} + +function wheel(chart, event) { + const {handlers: {onZoomComplete}, options: {zoom: zoomOptions}} = getState(chart); + + if (!wheelPreconditions(chart, event, zoomOptions)) { + return; + } + + const rect = event.target.getBoundingClientRect(); + const speed = 1 + (event.deltaY >= 0 ? -zoomOptions.wheel.speed : zoomOptions.wheel.speed); + const amount = { + x: speed, + y: speed, + focalPoint: { + x: event.clientX - rect.left, + y: event.clientY - rect.top + } + }; + + zoom(chart, amount); + + if (onZoomComplete) { + onZoomComplete(); + } +} + +function addDebouncedHandler(chart, name, handler, delay) { + if (handler) { + getState(chart).handlers[name] = debounce(() => helpers.callback(handler, [{chart}]), delay); + } +} + +function addListeners(chart, options) { + const canvas = chart.canvas; + const {wheel: wheelOptions, drag: dragOptions, onZoomComplete} = options.zoom; + + // Install listeners. Do this dynamically based on options so that we can turn zoom on and off + // We also want to make sure listeners aren't always on. E.g. if you're scrolling down a page + // and the mouse goes over a chart you don't want it intercepted unless the plugin is enabled + if (wheelOptions.enabled) { + addHandler(chart, canvas, 'wheel', wheel); + addDebouncedHandler(chart, 'onZoomComplete', onZoomComplete, 250); + } else { + removeHandler(chart, 'wheel'); + } + if (dragOptions.enabled) { + addHandler(chart, canvas, 'mousedown', mouseDown); + addHandler(chart, canvas.ownerDocument, 'mouseup', mouseUp); + } else { + removeHandler(chart, 'mousedown'); + removeHandler(chart, 'mousemove'); + removeHandler(chart, 'mouseup'); + } +} + +function removeListeners(chart) { + removeHandler(chart, 'mousedown'); + removeHandler(chart, 'mousemove'); + removeHandler(chart, 'mouseup'); + removeHandler(chart, 'wheel'); + removeHandler(chart, 'click'); +} + +function createEnabler(chart, state) { + return function(recognizer, event) { + const {pan: panOptions, zoom: zoomOptions = {}} = state.options; + if (!panOptions || !panOptions.enabled) { + return false; + } + const srcEvent = event && event.srcEvent; + if (!srcEvent) { // Sometimes Hammer queries this with a null event. + return true; + } + if (!state.panning && event.pointerType === 'mouse' && ( + keyNotPressed(getModifierKey(panOptions), srcEvent) || keyPressed(getModifierKey(zoomOptions.drag), srcEvent)) + ) { + helpers.callback(panOptions.onPanRejected, [{chart, event}]); + return false; + } + return true; + }; +} + +function pinchAxes(p0, p1) { + // fingers position difference + const pinchX = Math.abs(p0.clientX - p1.clientX); + const pinchY = Math.abs(p0.clientY - p1.clientY); + + // diagonal fingers will change both (xy) axes + const p = pinchX / pinchY; + let x, y; + if (p > 0.3 && p < 1.7) { + x = y = true; + } else if (pinchX > pinchY) { + x = true; + } else { + y = true; + } + return {x, y}; +} + +function handlePinch(chart, state, e) { + if (state.scale) { + const {center, pointers} = e; + // Hammer reports the total scaling. We need the incremental amount + const zoomPercent = 1 / state.scale * e.scale; + const rect = e.target.getBoundingClientRect(); + const pinch = pinchAxes(pointers[0], pointers[1]); + const mode = state.options.zoom.mode; + const amount = { + x: pinch.x && directionEnabled(mode, 'x', chart) ? zoomPercent : 1, + y: pinch.y && directionEnabled(mode, 'y', chart) ? zoomPercent : 1, + focalPoint: { + x: center.x - rect.left, + y: center.y - rect.top + } + }; + + zoom(chart, amount); + + // Keep track of overall scale + state.scale = e.scale; + } +} + +function startPinch(chart, state) { + if (state.options.zoom.pinch.enabled) { + state.scale = 1; + } +} + +function endPinch(chart, state, e) { + if (state.scale) { + handlePinch(chart, state, e); + state.scale = null; // reset + helpers.callback(state.options.zoom.onZoomComplete, [{chart}]); + } +} + +function handlePan(chart, state, e) { + const delta = state.delta; + if (delta) { + state.panning = true; + pan(chart, {x: e.deltaX - delta.x, y: e.deltaY - delta.y}, state.panScales); + state.delta = {x: e.deltaX, y: e.deltaY}; + } +} + +function startPan(chart, state, event) { + const {enabled, overScaleMode, onPanStart, onPanRejected} = state.options.pan; + if (!enabled) { + return; + } + const rect = event.target.getBoundingClientRect(); + const point = { + x: event.center.x - rect.left, + y: event.center.y - rect.top + }; + + if (helpers.callback(onPanStart, [{chart, event, point}]) === false) { + return helpers.callback(onPanRejected, [{chart, event}]); + } + + state.panScales = overScaleMode && getEnabledScalesByPoint(overScaleMode, point, chart); + state.delta = {x: 0, y: 0}; + clearTimeout(state.panEndTimeout); + handlePan(chart, state, event); +} + +function endPan(chart, state) { + state.delta = null; + if (state.panning) { + state.panEndTimeout = setTimeout(() => (state.panning = false), 500); + helpers.callback(state.options.pan.onPanComplete, [{chart}]); + } +} + +const hammers = new WeakMap(); +function startHammer(chart, options) { + const state = getState(chart); + const canvas = chart.canvas; + const {pan: panOptions, zoom: zoomOptions} = options; + + const mc = new Hammer__default['default'].Manager(canvas); + if (zoomOptions && zoomOptions.pinch.enabled) { + mc.add(new Hammer__default['default'].Pinch()); + mc.on('pinchstart', () => startPinch(chart, state)); + mc.on('pinch', (e) => handlePinch(chart, state, e)); + mc.on('pinchend', (e) => endPinch(chart, state, e)); + } + + if (panOptions && panOptions.enabled) { + mc.add(new Hammer__default['default'].Pan({ + threshold: panOptions.threshold, + enable: createEnabler(chart, state) + })); + mc.on('panstart', (e) => startPan(chart, state, e)); + mc.on('panmove', (e) => handlePan(chart, state, e)); + mc.on('panend', () => endPan(chart, state)); + } + + hammers.set(chart, mc); +} + +function stopHammer(chart) { + const mc = hammers.get(chart); + if (mc) { + mc.remove('pinchstart'); + mc.remove('pinch'); + mc.remove('pinchend'); + mc.remove('panstart'); + mc.remove('pan'); + mc.remove('panend'); + mc.destroy(); + hammers.delete(chart); + } +} + +var version = "1.1.1"; + +var Zoom = { + id: 'zoom', + + version, + + defaults: { + pan: { + enabled: false, + mode: 'xy', + threshold: 10, + modifierKey: null, + }, + zoom: { + wheel: { + enabled: false, + speed: 0.1, + modifierKey: null + }, + drag: { + enabled: false, + modifierKey: null + }, + pinch: { + enabled: false + }, + mode: 'xy', + } + }, + + start: function(chart, _args, options) { + const state = getState(chart); + state.options = options; + + if (Object.prototype.hasOwnProperty.call(options.zoom, 'enabled')) { + console.warn('The option `zoom.enabled` is no longer supported. Please use `zoom.wheel.enabled`, `zoom.drag.enabled`, or `zoom.pinch.enabled`.'); + } + + if (Hammer__default['default']) { + startHammer(chart, options); + } + + chart.pan = (delta, panScales, transition) => pan(chart, delta, panScales, transition); + chart.zoom = (args, transition) => zoom(chart, args, transition); + chart.zoomScale = (id, range, transition) => zoomScale(chart, id, range, transition); + chart.resetZoom = (transition) => resetZoom(chart, transition); + chart.getZoomLevel = () => getZoomLevel(chart); + }, + + beforeEvent(chart) { + const state = getState(chart); + if (state.panning || state.dragging) { + // cancel any event handling while panning or dragging + return false; + } + }, + + beforeUpdate: function(chart, args, options) { + const state = getState(chart); + state.options = options; + addListeners(chart, options); + }, + + beforeDatasetsDraw: function(chart, args, options) { + const {dragStart, dragEnd} = getState(chart); + + if (dragEnd) { + const {left, top, width, height} = computeDragRect(chart, options.zoom.mode, dragStart, dragEnd); + + const dragOptions = options.zoom.drag; + const ctx = chart.ctx; + + ctx.save(); + ctx.beginPath(); + ctx.fillStyle = dragOptions.backgroundColor || 'rgba(225,225,225,0.3)'; + ctx.fillRect(left, top, width, height); + + if (dragOptions.borderWidth > 0) { + ctx.lineWidth = dragOptions.borderWidth; + ctx.strokeStyle = dragOptions.borderColor || 'rgba(225,225,225)'; + ctx.strokeRect(left, top, width, height); + } + ctx.restore(); + } + }, + + stop: function(chart) { + removeListeners(chart); + + if (Hammer__default['default']) { + stopHammer(chart); + } + removeState(chart); + }, + + panFunctions, + + zoomFunctions, + + updateRangeFunctions +}; + +chart_js.Chart.register(Zoom); + +return Zoom; + +}))); diff --git a/src/fprime_gds/flask/static/addons/chart-display/sibling.js b/src/fprime_gds/flask/static/addons/chart-display/sibling.js new file mode 100644 index 00000000..e5783638 --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/sibling.js @@ -0,0 +1,77 @@ +/** + * Contains the functions and definitions needed for handling sibling charts and the syncing between them. + */ + + +/** + * Syncronize the axis of the supplied leader to the supplied follower + * @param leader: source of axis information + * @param follower: destination of axis information + */ +function syncSibling(leader, follower) { + const {min, max} = leader.scales.x; + follower.zoomScale("x", {min: min, max: max}, "none"); +} + + +export class SiblingSet { + + constructor() { + this.in_sync = false; + this._siblings = []; + + this.pause = this.get_guarded_function(this.__pauseAll); + this.reset = this.get_guarded_function(this.__resetAll); + this.syncToAll = this.get_guarded_function((input) => {this.__syncToAll(input.chart)}); + this.sync = this.get_guarded_function(this.__syncFromPrime); + } + + get_guarded_function(func) { + let _self = this; + return (...args) => { + if (_self.in_sync) { + func.apply(_self, args); + } + }; + } + + __syncFromPrime(sibling) { + let prime = this.prime(); + if (sibling == null || prime == null || prime == sibling) { + return; + } + syncSibling(this.prime(), sibling); + } + + __syncToAll(leader) { + let syncing_function = syncSibling.bind(undefined, leader); + if (leader == null) { + return; + } + this._siblings.filter((item) => {return item !== leader}).map(syncing_function); + } + + __pauseAll(pause) { + this._siblings.map((sibling) => {sibling.options.scales.x.realtime.pause = pause;}); + } + + __resetAll() { + this._siblings.map((sibling) => {sibling.resetZoom("none")}); + } + + prime() { + return (this._siblings) ? this._siblings[0] : null; + } + + add(sibling) { + if (this._siblings.indexOf(sibling) === -1) { + this._siblings.push(sibling); + } + } + remove(sibling) { + let index = this._siblings.indexOf(sibling); + if (index !== -1) { + this._siblings.slice(index, 1); + } + } +} \ No newline at end of file diff --git a/src/fprime_gds/flask/static/third-party/js/chart.js b/src/fprime_gds/flask/static/addons/chart-display/vendor/chart.js similarity index 100% rename from src/fprime_gds/flask/static/third-party/js/chart.js rename to src/fprime_gds/flask/static/addons/chart-display/vendor/chart.js diff --git a/src/fprime_gds/flask/static/third-party/js/chartjs-adapter-luxon.min.js b/src/fprime_gds/flask/static/addons/chart-display/vendor/chartjs-adapter-luxon.min.js similarity index 100% rename from src/fprime_gds/flask/static/third-party/js/chartjs-adapter-luxon.min.js rename to src/fprime_gds/flask/static/addons/chart-display/vendor/chartjs-adapter-luxon.min.js diff --git a/src/fprime_gds/flask/static/third-party/js/hammer.min.js b/src/fprime_gds/flask/static/addons/chart-display/vendor/hammer.min.js similarity index 100% rename from src/fprime_gds/flask/static/third-party/js/hammer.min.js rename to src/fprime_gds/flask/static/addons/chart-display/vendor/hammer.min.js diff --git a/src/fprime_gds/flask/static/addons/enabled.js b/src/fprime_gds/flask/static/addons/enabled.js index cc6059b6..193cfd65 100644 --- a/src/fprime_gds/flask/static/addons/enabled.js +++ b/src/fprime_gds/flask/static/addons/enabled.js @@ -1,2 +1,3 @@ // Add addon imports here, try used to prevent errors from crashing GDS import "./image-display/addon.js" +import "./chart-display/addon.js" diff --git a/src/fprime_gds/flask/static/js/datastore.js b/src/fprime_gds/flask/static/js/datastore.js index f1ad26b9..6ad061f3 100644 --- a/src/fprime_gds/flask/static/js/datastore.js +++ b/src/fprime_gds/flask/static/js/datastore.js @@ -33,6 +33,7 @@ export class DataStore { // Data stores used to store all data supplied to the system this.events = []; this.command_history = []; + this.latest_channels = []; this.channels = {}; this.commands = {}; this.logs ={"": ""}; @@ -41,6 +42,9 @@ export class DataStore { this.downfiles = []; this.upfiles = []; this.uploading = false; + + // Consumers + this.channel_consumers = []; } startup() { @@ -66,7 +70,7 @@ export class DataStore { _loader.registerPoller("channels", this.updateChannels.bind(this)); _loader.registerPoller("events", this.updateEvents.bind(this)); _loader.registerPoller("commands", this.updateCommandHistory.bind(this)); - _loader.registerPoller("logdata", this.updateLogs.bind(this)); + //_loader.registerPoller("logdata", this.updateLogs.bind(this)); _loader.registerPoller("upfiles", this.updateUpfiles.bind(this)); _loader.registerPoller("downfiles", this.updateDownfiles.bind(this)); } @@ -93,6 +97,14 @@ export class DataStore { let id = channel.id; this.channels[id] = channel; } + this.channel_consumers.forEach((consumer) => + { + try { + consumer.sendChannels(new_channels); + } catch (e) { + console.error(e); + } + }); this.updateActivity(new_channels, 0); } @@ -132,6 +144,16 @@ export class DataStore { this.active.splice(index, 1, false); } } + + registerChannelConsumer(consumer) { + this.channel_consumers.push(consumer); + } + deregisterChannelConsumer(consumer) { + let index = this.channel_consumers.indexOf(consumer); + if (index != -1) { + this.channel_consumers.splice(index, 1); + } + } }; diff --git a/src/fprime_gds/flask/static/js/vue-support/tabetc.js b/src/fprime_gds/flask/static/js/vue-support/tabetc.js index 7723ff14..679a3c0b 100644 --- a/src/fprime_gds/flask/static/js/vue-support/tabetc.js +++ b/src/fprime_gds/flask/static/js/vue-support/tabetc.js @@ -16,7 +16,6 @@ import "./event.js" import "./log.js" import "./uplink.js" import "./dashboard.js" -import "../../addons/chart-display/chart-addon.js" import {_datastore} from "../datastore.js"; /** diff --git a/src/fprime_gds/flask/static/js/vue-support/utils.js b/src/fprime_gds/flask/static/js/vue-support/utils.js index 1aee7e9b..a96e8709 100644 --- a/src/fprime_gds/flask/static/js/vue-support/utils.js +++ b/src/fprime_gds/flask/static/js/vue-support/utils.js @@ -52,9 +52,7 @@ export function filter(items, matching, ifun) { export function timeToString(time) { // If we have a workstation time, convert it to calendar time if (time.base.value == 2) { - let date = new Date(0); - date.setSeconds(time.seconds); - date.setMilliseconds(time.microseconds/1000); + let date = new Date((time.seconds * 1000) + (time.microseconds/1000)); return date.toISOString(); } return time.seconds + "." + time.microseconds; diff --git a/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-streaming.min.js b/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-streaming.min.js deleted file mode 100644 index b6412c37..00000000 --- a/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-streaming.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * chartjs-plugin-streaming v2.0.0 - * https://nagix.github.io/chartjs-plugin-streaming - * (c) 2017-2021 Akihiko Kusanagi - * Released under the MIT license - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("chart.js"),require("chart.js/helpers")):"function"==typeof define&&define.amd?define(["chart.js","chart.js/helpers"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).ChartStreaming=t(e.Chart,e.Chart.helpers)}(this,(function(e,t){"use strict";function o(e,t,o){return Math.min(Math.max(e,t),o)}function n(e,o){const n=e.options.realtime,a=e.chart.options.plugins.streaming;return t.valueOrDefault(n[o],a[o])}function a(e,{x:o,y:n},{xAxisID:a,yAxisID:i}){const s={};return t.each(o,(e=>{s[e]={axisId:a}})),t.each(n,(e=>{s[e]={axisId:i}})),s}const i="undefined"==typeof window?t.noop:window.cancelAnimationFrame;function s(e){const t=e.frameRequestID;t&&(i.call(window,t),delete e.frameRequestID)}function r(e){const t=e.refreshTimerID;t&&(clearInterval(t),delete e.refreshTimerID,delete e.refreshInterval)}function l(e,o,n){e.refreshTimerID||(e.refreshTimerID=setInterval((()=>{const n=t.callback(o);e.refreshInterval===n||isNaN(n)||(r(e),l(e,o,n))}),n||0),e.refreshInterval=n||0)}function c(e,o,n){return o="number"==typeof o?o:e.parse(o),t.isFinite(o)?{value:e.getPixelForValue(o),transitionable:!0}:{value:n}}function d(){const t=e.registry.getElement("boxAnnotation"),o=e.registry.getElement("lineAnnotation"),n=e.registry.getElement("pointAnnotation"),a=t.prototype.resolveElementProperties,i=o.prototype.resolveElementProperties,s=n.prototype.resolveElementProperties;t.prototype.resolveElementProperties=function(e,t){return function(e,t,o){const{scales:n,chartArea:a}=t,{xScaleID:i,yScaleID:s,xMin:r,xMax:l,yMin:d,yMax:u}=o,m=n[i],p=n[s],{top:f,left:h,bottom:g,right:y}=a,x=e.$streaming={};if(m){const e=c(m,r,h),t=c(m,l,y),o=e.value>t.value;e.transitionable&&(x[o?"x2":"x"]={axisId:i}),t.transitionable&&(x[o?"x":"x2"]={axisId:i}),e.transitionable!==t.transitionable&&(x.width={axisId:i,reverse:e.transitionable})}if(p){const e=c(p,d,f),t=c(p,u,g),o=e.value>t.value;e.transitionable&&(x[o?"y2":"y"]={axisId:s}),t.transitionable&&(x[o?"y":"y2"]={axisId:s}),e.transitionable!==t.transitionable&&(x.height={axisId:s,reverse:e.transitionable})}}(this,e,t),a.call(this,e,t)},o.prototype.resolveElementProperties=function(e,t){const o=e.chartArea;e.chartArea=function(e,t,o){const{scales:n,chartArea:a}=t,{scaleID:i,value:s}=o,r=n[i],{top:l,left:d,bottom:u,right:m}=a,p=e.$streaming={};if(r){const e=r.isHorizontal();return c(r,s).transitionable&&(p[e?"x":"y"]={axisId:i},p[e?"x2":"y2"]={axisId:i}),e?{top:l,bottom:u}:{left:d,right:m}}const{xScaleID:f,yScaleID:h,xMin:g,xMax:y,yMin:x,yMax:b}=o,v=n[f],I=n[h],D={};if(v){const e=c(v,g),t=c(v,y);e.transitionable?p.x={axisId:f}:D.left=d,t.transitionable?p.x2={axisId:f}:D.right=m}if(I){const e=c(I,x),t=c(I,b);e.transitionable?p.y={axisId:h}:D.top=l,t.transitionable?p.y2={axisId:h}:D.bottom=u}return D}(this,e,t);const n=i.call(this,e,t);return e.chartArea=o,n},n.prototype.resolveElementProperties=function(e,t){return function(e,t,o){const n=t.scales,{xScaleID:a,yScaleID:i,xValue:s,yValue:r}=o,l=n[a],d=n[i],u=e.$streaming={};l&&c(l,s).transitionable&&(u.x={axisId:a});d&&c(d,r).transitionable&&(u.y={axisId:i})}(this,e,t),s.call(this,e,t)}}const u={x:["x","caretX"],y:["y","caretY"]};function m(...e){const t=this,o=t.getActiveElements()[0];if(o){const e=t._chart.getDatasetMeta(o.datasetIndex);t.$streaming=a(0,u,e)}else t.$streaming={};t.constructor.prototype.update.call(t,...e)}const p=new WeakMap;function f(e){const{originalScaleOptions:o}=function(e){let t=p.get(e);return t||(t={originalScaleOptions:{}},p.set(e,t)),t}(e),a=e.scales;return t.each(a,(e=>{const t=e.id;o[t]||(o[t]={duration:n(e,"duration"),delay:n(e,"delay")})})),t.each(o,((e,t)=>{a[t]||delete o[t]})),o}function h(e,t,a,i){const{chart:s,axis:r}=e,{minDuration:l=0,maxDuration:c=1/0,minDelay:d=-1/0,maxDelay:u=1/0}=i&&i[r]||{},m=e.options.realtime,p=n(e,"duration"),h=n(e,"delay"),g=o(p*(2-t),l,c);let y,x;return f(s),y=e.isHorizontal()?(e.right-a.x)/(e.right-e.left):(e.bottom-a.y)/(e.bottom-e.top),x=h+y*(p-g),m.duration=g,m.delay=o(x,d,u),g!==e.max-e.min}function g(e,t,a){const{chart:i,axis:s}=e,{minDelay:r=-1/0,maxDelay:l=1/0}=a&&a[s]||{},c=n(e,"delay")+(e.getValueForPixel(t)-e.getValueForPixel(0));return f(i),e.options.realtime.delay=o(c,r,l),!0}function y(e,o){const n=o.$streaming;if(n.zoomPlugin!==e){const a=n.resetZoom=o.resetZoom;!function(e){e.zoomFunctions.realtime=h,e.panFunctions.realtime=g}(e),o.resetZoom=e=>{!function(e){const o=f(e);t.each(e.scales,(e=>{const t=e.options.realtime;if(t){const n=o[e.id];n?(t.duration=n.duration,t.delay=n.delay):(delete t.duration,delete t.delay)}}))}(o),a(e)},n.zoomPlugin=e}}function x(e){const t=e.$streaming;t.zoomPlugin&&(e.resetZoom=t.resetZoom,function(e){p.delete(e)}(e),delete t.resetZoom,delete t.zoomPlugin)}const b={millisecond:{common:!0,size:1,steps:[1,2,5,10,20,50,100,250,500]},second:{common:!0,size:1e3,steps:[1,2,5,10,15,30]},minute:{common:!0,size:6e4,steps:[1,2,5,10,15,30]},hour:{common:!0,size:36e5,steps:[1,2,3,6,12]},day:{common:!0,size:864e5,steps:[1,2,5]},week:{common:!1,size:6048e5,steps:[1,2,3,4]},month:{common:!0,size:2628e6,steps:[1,2,3]},quarter:{common:!1,size:7884e6,steps:[1,2,3,4]},year:{common:!0,size:3154e7}},v=Object.keys(b);function I(e,o,n){if(n){if(n.length){const{lo:a,hi:i}=t._lookup(n,o);e[n[a]>=o?n[a]:n[i]]=!0}}else e[o]=!0}const D=["pointBackgroundColor","pointBorderColor","pointBorderWidth","pointRadius","pointRotation","pointStyle","pointHitRadius","pointHoverBackgroundColor","pointHoverBorderColor","pointHoverBorderWidth","pointHoverRadius","backgroundColor","borderColor","borderSkipped","borderWidth","hoverBackgroundColor","hoverBorderColor","hoverBorderWidth","hoverRadius","hitRadius","radius","rotation"];function k(e,o,n){const a=e.$animations||{};t.each(e.$streaming,((i,s)=>{if(i.axisId===o){const o=i.reverse?-n:n,r=a[s];t.isFinite(e[s])&&(e[s]-=o),r&&(r._from-=o,r._to-=o)}}))}class w extends e.TimeScale{constructor(e){super(e),this.$realtime=this.$realtime||{}}init(e,o){const a=this;super.init(e,o),l(a.$realtime,(()=>{const e=a.chart,o=n(a,"onRefresh");return t.callback(o,[e],a),function(e){const{chart:o,id:a,max:i}=e,s=n(e,"duration"),r=n(e,"delay"),l=n(e,"ttl"),c=n(e,"pause"),d=Date.now()-(isNaN(l)?s+r:l);let u,m,p,f;t.each(o.data.datasets,((e,n)=>{const s=o.getDatasetMeta(n),r=a===s.xAxisID?"x":a===s.yAxisID&&"y";if(r){const a=s.controller,h=e.data,g=h.length;if(c){for(u=0;u{t.isArray(e[o])&&e[o].splice(m,p)})),t.each(e.datalabels,(e=>{t.isArray(e)&&e.splice(m,p)})),"object"!=typeof h[0]&&(f={start:m,count:p}),t.each(o._active,((e,t)=>{e.datasetIndex===n&&e.index>=m&&(e.index>=m+p?e.index-=p:o._active.splice(t,1))}),null,!0)}})),f&&o.data.labels.splice(f.start,f.count)}(a),e.update("quiet"),n(a,"refresh")}))}update(e,o,a){const i=this,{$realtime:r,options:l}=i,{bounds:c,offset:d,ticks:u}=l,{autoSkip:m,source:p,major:f}=u,h=f.enabled;n(i,"pause")?s(r):(r.frameRequestID||(r.head=Date.now()),function(e,o){if(!e.frameRequestID){const n=()=>{const a=e.nextRefresh||0,i=Date.now();if(a<=i){const n=t.callback(o),a=1e3/(Math.max(n,0)||30),s=e.nextRefresh+a||0;e.nextRefresh=s>i?s:i+a}e.frameRequestID=t.requestAnimFrame.call(window,n)};e.frameRequestID=t.requestAnimFrame.call(window,n)}}(r,(()=>{const e=i.chart,o=e.$streaming;return function(e){const{chart:o,id:a,$realtime:i}=e,s=n(e,"duration"),r=n(e,"delay"),l=e.isHorizontal(),c=l?e.width:e.height,d=Date.now(),u=o.tooltip,m=function(e){const t=e.$streaming.annotationPlugin;if(t){const o=t._getState(e);return o&&o.elements||[]}return[]}(o);let p=c*(d-i.head)/s;l===!!e.options.reverse&&(p=-p),t.each(o.data.datasets,((e,t)=>{const n=o.getDatasetMeta(t),{data:i=[],dataset:s}=n;for(let e=0,t=i.length;el.shift(),set:t.noop}),Object.defineProperty(e,"max",{get:()=>r.shift(),set:t.noop});const c=super.buildTicks();return delete e.min,delete e.max,e.min=s,e.max=i,c}calculateLabelRotation(){const e=this.options.ticks,t=e.maxRotation;e.maxRotation=e.minRotation||0,super.calculateLabelRotation(),e.maxRotation=t}fit(){const e=this,t=e.options;super.fit(),t.ticks.display&&t.display&&e.isHorizontal()&&(e.paddingLeft=3,e.paddingRight=3,e._handleMargins())}draw(e){const o=this,{chart:n,ctx:a}=o,i=o.isHorizontal()?{left:e.left,top:0,right:e.right,bottom:n.height}:{left:0,top:e.top,right:n.width,bottom:e.bottom};o._gridLineItems=null,o._labelItems=null,t.clipArea(a,i),super.draw(e),t.unclipArea(a)}destroy(){const e=this.$realtime;s(e),r(e)}_generate(){const e=this,o=e._adapter,a=n(e,"duration"),i=n(e,"delay"),s=n(e,"refresh"),r=e.$realtime.head-i,l=r-a,c=e._getLabelCapacity(l),{time:d,ticks:u}=e.options,m=d.unit||function(e,t,o,n){const a=o-t,i=v.length;for(let t=v.indexOf(e);t1e5*f)throw new Error(l+" and "+r+" are too far apart with stepSize of "+f+" "+m);k=R,g&&p&&!y&&!d.round&&(k=+o.startOf(k,p),k=+o.add(k,~~((R-k)/(x.size*f))*f,m));const $="data"===u.source&&e.getDataTimestamps();for(w=0;ke-t)).map((e=>+e))}}w.id="realtime",w.defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},realtime:{},ticks:{autoSkip:!1,source:"auto",major:{enabled:!0}}},e.defaults.describe("scale.realtime",{_scriptable:e=>"onRefresh"!==e});e.defaults.set("transitions",{quiet:{animation:{duration:0}}});const R={x:["x","cp1x","cp2x"],y:["y","cp1y","cp2y"]};function $(o){const n=this;"quiet"===o&&t.each(n.data.datasets,((t,o)=>{n.getDatasetMeta(o).controller._setStyle=function(t,o,n,a){e.DatasetController.prototype._setStyle.call(this,t,o,"quiet",a)}})),e.Chart.prototype.update.call(n,o),"quiet"===o&&t.each(n.data.datasets,((e,t)=>{delete n.getDatasetMeta(t).controller._setStyle}))}function E(e){const t=e.$streaming;e.render(),t.lastMouseEvent&&setTimeout((()=>{const o=t.lastMouseEvent;o&&e._eventHandler(o)}),0)}const M=[{id:"streaming",version:"2.0.0",beforeInit(e){const o=e.$streaming=e.$streaming||{render:E},n=o.canvas=e.canvas,a=o.mouseEventListener=n=>{const a=t.getRelativePosition(n,e);o.lastMouseEvent={type:"mousemove",chart:e,native:n,x:a.x,y:a.y}};n.addEventListener("mousedown",a),n.addEventListener("mouseup",a)},afterInit(e){e.update=$},beforeUpdate(o){const{scales:n,elements:a}=o.options,i=o.tooltip;t.each(n,(({type:e})=>{"realtime"===e&&(a.line.capBezierPoints=!1)})),i&&(i.update=m);try{!function(e,t){const o=t.$streaming;if(o.annotationPlugin!==e){const t=e.afterUpdate;d(),o.annotationPlugin=e,e.afterUpdate=(e,o,n)=>{const a=o.mode,i=n.animation;"quiet"===a&&(n.animation=!1),t.call(this,e,o,n),"quiet"===a&&(n.animation=i)}}}(e.registry.getPlugin("annotation"),o)}catch(e){!function(e){delete e.$streaming.annotationPlugin}(o)}try{y(e.registry.getPlugin("zoom"),o)}catch(e){x(o)}},beforeDatasetUpdate(e,o){const{meta:n,mode:a}=o;if("quiet"===a){const{controller:e,$animations:o}=n;o&&o.visible&&o.visible._active&&(e.updateElement=t.noop,e.updateSharedOptions=t.noop)}},afterDatasetUpdate(e,t){const{meta:o,mode:n}=t,{data:i=[],dataset:s,controller:r}=o;for(let e=0,t=i.length;e{e instanceof w&&e.destroy()}))},defaults:{duration:1e4,delay:0,frameRate:30,refresh:1e3,onRefresh:null,pause:!1,ttl:void 0},descriptors:{_scriptable:e=>"onRefresh"!==e}},w];return e.Chart.register(M),M})); diff --git a/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-zoom.min.js b/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-zoom.min.js deleted file mode 100644 index 6391c5ce..00000000 --- a/src/fprime_gds/flask/static/third-party/js/chartjs-plugin-zoom.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! -* chartjs-plugin-zoom v1.1.1 -* undefined - * (c) 2016-2021 chartjs-plugin-zoom Contributors - * Released under the MIT License - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("chart.js"),require("hammerjs"),require("chart.js/helpers")):"function"==typeof define&&define.amd?define(["chart.js","hammerjs","chart.js/helpers"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).ChartZoom=t(e.Chart,e.Hammer,e.Chart.helpers)}(this,(function(e,t,n){"use strict";function o(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var a=o(t);const i=e=>e&&e.enabled&&e.modifierKey,c=(e,t)=>e&&t[e+"Key"],r=(e,t)=>e&&!t[e+"Key"];function l(e,t,n){return void 0===e||("string"==typeof e?-1!==e.indexOf(t):"function"==typeof e&&-1!==e({chart:n}).indexOf(t))}function s(e,t,o){const a=function({x:e,y:t},n){const o=n.scales,a=Object.keys(o);for(let n=0;n=i.top&&t<=i.bottom&&e>=i.left&&e<=i.right)return i}return null}(t,o);if(a&&l(e,a.axis,o))return[a];const i=[];return n.each(o.scales,(function(t){l(e,t.axis,o)||i.push(t)})),i}const m=new WeakMap;function u(e){let t=m.get(e);return t||(t={originalScaleLimits:{},updatedScaleLimits:{},handlers:{},panDelta:{}},m.set(e,t)),t}function d(e,t,n){const o=e.max-e.min,a=o*(t-1),i=e.isHorizontal()?n.x:n.y,c=(e.getValueForPixel(i)-e.min)/o||0;return{min:a*c,max:a*(1-c)}}function f(e,t,o,a,i){let c=o[a];if("original"===c){const o=e.originalScaleLimits[t.id][a];c=n.valueOrDefault(o.options,o.scale)}return n.valueOrDefault(c,i)}function h(e,{min:t,max:n},o,a=!1){const i=u(e.chart),{id:c,axis:r,options:l}=e,s=o&&(o[c]||o[r])||{},{minRange:m=0}=s,d=f(i,e,s,"min",-1/0),h=f(i,e,s,"max",1/0),p=Math.max(t,d),g=Math.min(n,h),x=a?Math.max(g-p,m):e.max-e.min;if(g-p!==x)if(d>g-x)t=p,n=p+x;else if(h0===e||isNaN(e)?0:e<0?Math.min(Math.round(e),-1):Math.max(Math.round(e),1);const g={second:500,minute:3e4,hour:18e5,day:432e5,week:3024e5,month:1296e6,quarter:5184e6,year:157248e5};function x(e,t,n,o=!1){const{min:a,max:i,options:c}=e,r=c.time&&c.time.round,l=g[r]||0,s=e.getValueForPixel(e.getPixelForValue(a+l)-t),m=e.getValueForPixel(e.getPixelForValue(i+l)-t),{min:u=-1/0,max:d=1/0}=o&&n&&n[e.axis]||{};return!!(isNaN(s)||isNaN(m)||sd)||h(e,{min:s,max:m},n,o)}function b(e,t,n){return x(e,t,n,!0)}const y={category:function(e,t,n,o){const a=d(e,t,n);return e.min===e.max&&t<1&&function(e){const t=e.getLabels().length-1;e.min>0&&(e.min-=1),e.maxr&&(a=Math.max(0,a-l),i=1===c?a:a+c,s=0===a),h(e,{min:a,max:i},n)||s},default:x,logarithmic:b,timeseries:b};function z(e,t){n.each(e,((n,o)=>{t[o]||delete e[o]}))}function M(e,t){const{scales:o}=e,{originalScaleLimits:a,updatedScaleLimits:i}=t;return n.each(o,(function(e){(function(e,t,n){const{id:o,options:{min:a,max:i}}=e;if(!t[o]||!n[o])return!0;const c=n[o];return c.min!==a||c.max!==i})(e,a,i)&&(a[e.id]={min:{scale:e.min,options:e.options.min},max:{scale:e.max,options:e.options.max}})})),z(a,o),z(i,o),a}function w(e,t,o,a){const i=y[e.type]||y.default;n.callback(i,[e,t,o,a])}function k(e){const t=e.chartArea;return{x:(t.left+t.right)/2,y:(t.top+t.bottom)/2}}function S(e,t,o="none"){const{x:a=1,y:i=1,focalPoint:c=k(e)}="number"==typeof t?{x:t,y:t}:t,r=u(e),{options:{limits:m,zoom:d}}=r,{mode:f="xy",overScaleMode:h}=d||{};M(e,r);const p=1!==a&&l(f,"x",e),g=1!==i&&l(f,"y",e),x=h&&s(h,c,e);n.each(x||e.scales,(function(e){e.isHorizontal()&&p?w(e,a,c,m):!e.isHorizontal()&&g&&w(e,i,c,m)})),e.update(o),n.callback(d.onZoom,[{chart:e}])}function P(e,t,n){const o=e.getValueForPixel(t),a=e.getValueForPixel(n);return{min:Math.min(o,a),max:Math.max(o,a)}}function C(e){const t=u(e);let o=1,a=1;return n.each(e.scales,(function(e){const i=function(e,t){const o=e.originalScaleLimits[t];if(!o)return;const{min:a,max:i}=o;return n.valueOrDefault(i.options,i.scale)-n.valueOrDefault(a.options,a.scale)}(t,e.id);if(i){const t=Math.round(i/(e.max-e.min)*100)/100;o=Math.min(o,t),a=Math.max(a,t)}})),o<1?o:a}function j(e,t,o,a){const{panDelta:i}=a,c=i[e.id]||0;n.sign(c)===n.sign(t)&&(t+=c);const r=v[e.type]||v.default;n.callback(r,[e,t,o])?i[e.id]=0:i[e.id]=t}function Z(e,t,o,a="none"){const{x:i=0,y:c=0}="number"==typeof t?{x:t,y:t}:t,r=u(e),{options:{pan:s,limits:m}}=r,{mode:d="xy",onPan:f}=s||{};M(e,r);const h=0!==i&&l(d,"x",e),p=0!==c&&l(d,"y",e);n.each(o||e.scales,(function(e){e.isHorizontal()&&h?j(e,i,m,r):!e.isHorizontal()&&p&&j(e,c,m,r)})),e.update(a),n.callback(f,[{chart:e}])}function R(e,t){const{handlers:n}=u(e),o=n[t];o&&o.target&&(o.target.removeEventListener(t,o),delete n[t])}function Y(e,t,n,o){const{handlers:a,options:i}=u(e);R(e,n),a[n]=t=>o(e,t,i),a[n].target=t,t.addEventListener(n,a[n])}function L(e,t){const n=u(e);n.dragStart&&(n.dragging=!0,n.dragEnd=t,e.update("none"))}function T(e,t,o){const{onZoomStart:a,onZoomRejected:i}=o;if(a){const{left:o,top:c}=t.target.getBoundingClientRect(),r={x:t.clientX-o,y:t.clientY-c};if(!1===n.callback(a,[{chart:e,event:t,point:r}]))return n.callback(i,[{chart:e,event:t}]),!1}}function X(e,t){const o=u(e),{pan:a,zoom:l={}}=o.options;if(c(i(a),t)||r(i(l.drag),t))return n.callback(l.onZoomRejected,[{chart:e,event:t}]);!1!==T(e,t,l)&&(o.dragStart=t,Y(e,e.canvas,"mousemove",L))}function D(e,t,n,o){const{left:a,top:i}=n.target.getBoundingClientRect(),c=l(t,"x",e),r=l(t,"y",e);let{top:s,left:m,right:u,bottom:d,width:f,height:h}=e.chartArea;c&&(m=Math.min(n.clientX,o.clientX)-a,u=Math.max(n.clientX,o.clientX)-a),r&&(s=Math.min(n.clientY,o.clientY)-i,d=Math.max(n.clientY,o.clientY)-i);const p=u-m,g=d-s;return{left:m,top:s,right:u,bottom:d,width:p,height:g,zoomX:c&&p?1+(f-p)/f:1,zoomY:r&&g?1+(h-g)/h:1}}function E(e,t){const o=u(e);if(!o.dragStart)return;R(e,"mousemove");const{mode:a,onZoomComplete:i,drag:{threshold:c=0}}=o.options.zoom,r=D(e,a,o.dragStart,t),s=l(a,"x",e)?r.width:0,m=l(a,"y",e)?r.height:0,d=Math.sqrt(s*s+m*m);if(o.dragStart=o.dragEnd=null,d<=c)return o.dragging=!1,void e.update("none");!function(e,t,o,a="none"){const i=u(e),{options:{limits:c,zoom:r}}=i,{mode:s="xy"}=r;M(e,i);const m=l(s,"x",e),d=l(s,"y",e);n.each(e.scales,(function(e){e.isHorizontal()&&m?h(e,P(e,t.x,o.x),c,!0):!e.isHorizontal()&&d&&h(e,P(e,t.y,o.y),c,!0)})),e.update(a),n.callback(r.onZoom,[{chart:e}])}(e,{x:r.left,y:r.top},{x:r.right,y:r.bottom},"zoom"),setTimeout((()=>o.dragging=!1),500),n.callback(i,[{chart:e}])}function F(e,t){const{handlers:{onZoomComplete:o},options:{zoom:a}}=u(e);if(!function(e,t,o){if(r(i(o.wheel),t))n.callback(o.onZoomRejected,[{chart:e,event:t}]);else if(!1!==T(e,t,o)&&(t.cancelable&&t.preventDefault(),void 0!==t.deltaY))return!0}(e,t,a))return;const c=t.target.getBoundingClientRect(),l=1+(t.deltaY>=0?-a.wheel.speed:a.wheel.speed);S(e,{x:l,y:l,focalPoint:{x:t.clientX-c.left,y:t.clientY-c.top}}),o&&o()}function H(e,t,o,a){o&&(u(e).handlers[t]=function(e,t){let n;return function(){return clearTimeout(n),n=setTimeout(e,t),t}}((()=>n.callback(o,[{chart:e}])),a))}function O(e,t){return function(o,a){const{pan:l,zoom:s={}}=t.options;if(!l||!l.enabled)return!1;const m=a&&a.srcEvent;return!m||(!(!t.panning&&"mouse"===a.pointerType&&(r(i(l),m)||c(i(s.drag),m)))||(n.callback(l.onPanRejected,[{chart:e,event:a}]),!1))}}function V(e,t,n){if(t.scale){const{center:o,pointers:a}=n,i=1/t.scale*n.scale,c=n.target.getBoundingClientRect(),r=function(e,t){const n=Math.abs(e.clientX-t.clientX),o=Math.abs(e.clientY-t.clientY),a=n/o;let i,c;return a>.3&&a<1.7?i=c=!0:n>o?i=!0:c=!0,{x:i,y:c}}(a[0],a[1]),s=t.options.zoom.mode;S(e,{x:r.x&&l(s,"x",e)?i:1,y:r.y&&l(s,"y",e)?i:1,focalPoint:{x:o.x-c.left,y:o.y-c.top}}),t.scale=n.scale}}function K(e,t,n){const o=t.delta;o&&(t.panning=!0,Z(e,{x:n.deltaX-o.x,y:n.deltaY-o.y},t.panScales),t.delta={x:n.deltaX,y:n.deltaY})}const N=new WeakMap;function q(e,t){const o=u(e),i=e.canvas,{pan:c,zoom:r}=t,l=new a.default.Manager(i);r&&r.pinch.enabled&&(l.add(new a.default.Pinch),l.on("pinchstart",(()=>function(e,t){t.options.zoom.pinch.enabled&&(t.scale=1)}(0,o))),l.on("pinch",(t=>V(e,o,t))),l.on("pinchend",(t=>function(e,t,o){t.scale&&(V(e,t,o),t.scale=null,n.callback(t.options.zoom.onZoomComplete,[{chart:e}]))}(e,o,t)))),c&&c.enabled&&(l.add(new a.default.Pan({threshold:c.threshold,enable:O(e,o)})),l.on("panstart",(t=>function(e,t,o){const{enabled:a,overScaleMode:i,onPanStart:c,onPanRejected:r}=t.options.pan;if(!a)return;const l=o.target.getBoundingClientRect(),m={x:o.center.x-l.left,y:o.center.y-l.top};if(!1===n.callback(c,[{chart:e,event:o,point:m}]))return n.callback(r,[{chart:e,event:o}]);t.panScales=i&&s(i,m,e),t.delta={x:0,y:0},clearTimeout(t.panEndTimeout),K(e,t,o)}(e,o,t))),l.on("panmove",(t=>K(e,o,t))),l.on("panend",(()=>function(e,t){t.delta=null,t.panning&&(t.panEndTimeout=setTimeout((()=>t.panning=!1),500),n.callback(t.options.pan.onPanComplete,[{chart:e}]))}(e,o)))),N.set(e,l)}var B={id:"zoom",version:"1.1.1",defaults:{pan:{enabled:!1,mode:"xy",threshold:10,modifierKey:null},zoom:{wheel:{enabled:!1,speed:.1,modifierKey:null},drag:{enabled:!1,modifierKey:null},pinch:{enabled:!1},mode:"xy"}},start:function(e,t,o){u(e).options=o,Object.prototype.hasOwnProperty.call(o.zoom,"enabled")&&console.warn("The option `zoom.enabled` is no longer supported. Please use `zoom.wheel.enabled`, `zoom.drag.enabled`, or `zoom.pinch.enabled`."),a.default&&q(e,o),e.pan=(t,n,o)=>Z(e,t,n,o),e.zoom=(t,n)=>S(e,t,n),e.zoomScale=(t,n,o)=>function(e,t,n,o="none"){M(e,u(e)),h(e.scales[t],n,void 0,!0),e.update(o)}(e,t,n,o),e.resetZoom=t=>function(e,t="default"){const o=u(e),a=M(e,o);n.each(e.scales,(function(e){const t=e.options;a[e.id]?(t.min=a[e.id].min.options,t.max=a[e.id].max.options):(delete t.min,delete t.max)})),e.update(t),n.callback(o.options.zoom.onZoomComplete,[{chart:e}])}(e,t),e.getZoomLevel=()=>C(e)},beforeEvent(e){const t=u(e);if(t.panning||t.dragging)return!1},beforeUpdate:function(e,t,n){u(e).options=n,function(e,t){const n=e.canvas,{wheel:o,drag:a,onZoomComplete:i}=t.zoom;o.enabled?(Y(e,n,"wheel",F),H(e,"onZoomComplete",i,250)):R(e,"wheel"),a.enabled?(Y(e,n,"mousedown",X),Y(e,n.ownerDocument,"mouseup",E)):(R(e,"mousedown"),R(e,"mousemove"),R(e,"mouseup"))}(e,n)},beforeDatasetsDraw:function(e,t,n){const{dragStart:o,dragEnd:a}=u(e);if(a){const{left:t,top:i,width:c,height:r}=D(e,n.zoom.mode,o,a),l=n.zoom.drag,s=e.ctx;s.save(),s.beginPath(),s.fillStyle=l.backgroundColor||"rgba(225,225,225,0.3)",s.fillRect(t,i,c,r),l.borderWidth>0&&(s.lineWidth=l.borderWidth,s.strokeStyle=l.borderColor||"rgba(225,225,225)",s.strokeRect(t,i,c,r)),s.restore()}},stop:function(e){!function(e){R(e,"mousedown"),R(e,"mousemove"),R(e,"mouseup"),R(e,"wheel"),R(e,"click")}(e),a.default&&function(e){const t=N.get(e);t&&(t.remove("pinchstart"),t.remove("pinch"),t.remove("pinchend"),t.remove("panstart"),t.remove("pan"),t.remove("panend"),t.destroy(),N.delete(e))}(e),function(e){m.delete(e)}(e)},panFunctions:v,zoomFunctions:y};return e.Chart.register(B),B})); From d06dcfb8cb844ea49bee5520607ea5345cf44a89 Mon Sep 17 00:00:00 2001 From: Michael D Starch Date: Mon, 2 Aug 2021 09:34:10 -0700 Subject: [PATCH 2/5] lestarch: spelling issues w.r.t. chart third party libraries --- .github/actions/spelling/excludes.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 81c27862..6fd0b1e3 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -7,6 +7,8 @@ ignore$ ^Autocoders/Python/test/.*\.xml$ /doc/xml/ /third-party/ +/vendor/ +/modified-vendor/ \.min\. \.bak$ \.bin$ From 6b721623a4aa3e6292c84c03c32179504a7b22c2 Mon Sep 17 00:00:00 2001 From: M Starch Date: Mon, 2 Aug 2021 14:35:26 -0700 Subject: [PATCH 3/5] lestarch: cleaning up chart code for PR --- .../addons/chart-display/addon-templates.js | 8 ++- .../static/addons/chart-display/addon.js | 37 +++++++------ .../static/addons/chart-display/config.js | 41 +++++++++++--- .../static/addons/chart-display/sibling.js | 54 +++++++++++++++++-- .../flask/static/js/vue-support/utils.js | 12 ++++- 5 files changed, 121 insertions(+), 31 deletions(-) diff --git a/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js b/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js index 2e92f9b3..ed84b054 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js +++ b/src/fprime_gds/flask/static/addons/chart-display/addon-templates.js @@ -1,4 +1,10 @@ - +/** + * addon-templates.js: + * + * Contains the HTML templates for the chart addon. This includes a chart wrapper and the chart itself. + * + * @type {string} + */ export let chart_wrapper_template = `
diff --git a/src/fprime_gds/flask/static/addons/chart-display/addon.js b/src/fprime_gds/flask/static/addons/chart-display/addon.js index 3047560a..e2de25e5 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/addon.js +++ b/src/fprime_gds/flask/static/addons/chart-display/addon.js @@ -1,7 +1,8 @@ /** * addons/chart-display.js: * - * Visualize selected telemetry channels using time series charts + * Visualize selected telemetry channels using time series charts. This is done in realtime. Time-shifted signals + * will need to be panned into focus. * * @author saba-ja */ @@ -10,6 +11,7 @@ import {chart_wrapper_template, chart_display_template} from "./addon-templates. import { _datastore } from '../../js/datastore.js'; import {_loader} from "../../js/loader.js"; import {SiblingSet} from './sibling.js'; +import {timeToDate} from "../../js/vue-support/utils.js" import './vendor/chart.js'; import './vendor/chartjs-adapter-luxon.min.js'; @@ -18,35 +20,30 @@ import './vendor/hammer.min.js'; import './modified-vendor/chartjs-plugin-zoom.js'; import './modified-vendor/chartjs-plugin-streaming.js'; - -function timeToDate(time) { - let date = new Date((time.seconds * 1000) + (time.microseconds/1000)); - return date; -} - /** - * Wrapper component to allow user add multiple charts to the same page + * Wrapper component to allow user add multiple charts to the same page. This component handles the functions for + * selecting the chart channel before the chart is created. */ Vue.component("chart-wrapper", { data: function () { return { locked: false, isHelpActive: true, - wrappers: [{"id": 0}], + wrappers: [{"id": 0}], // Starts with a single chart siblings: new SiblingSet() }; }, template: chart_wrapper_template, methods: { /** - * Add new chart + * Add new chart handling the Chart+ button. */ addChart(type) { this.wrappers.push({'id': this.counter}); this.counter += 1; }, /** - * Remove chart with the given id + * Remove chart with the given id for handling the X button on a chart wrapper */ deleteChart(id) { const index = this.wrappers.findIndex(f => f.id === id); @@ -56,14 +53,13 @@ Vue.component("chart-wrapper", { }); /** - * Main chart component + * Main chart component. This displays the chart JS object and routes data too it. */ Vue.component("chart-display", { template: chart_display_template, props: ["id", "siblings"], data: function () { let names = Object.values(_loader.endpoints["channel-dict"].data).map((value) => {return value.full_name}); - return { channelNames: names, selected: null, @@ -107,12 +103,15 @@ Vue.component("chart-display", { this.siblings.add(this.chart); }, /** - * Reset chart zoom back to default + * Reset chart zoom back to default. This should affect all siblings when timescales are locked. */ resetZoom() { this.chart.resetZoom("none"); this.siblings.reset(); }, + /** + * Destroy a chart object. + */ destroy() { // Guard against destroying that which is destroyed if (this.chart == null) { @@ -133,21 +132,27 @@ Vue.component("chart-display", { this.destroy(); this.$emit('delete-chart', id); }, - + /** + * Callback to handle new channels being pushed at this object. + * @param channels: new set of channels (unfiltered) + */ sendChannels(channels) { if (this.selected == null || this.chart == null) { return; } let name = this.selected; + // Filter channels down to the graphed channel let new_channels = channels.filter((channel) => { - return channel.template.full_name == name + return channel.template.full_name === name }); + // Convert to chart JS format new_channels = new_channels.map( (channel) => { return {x: timeToDate(channel.time), y: channel.val} } ); + // Graph and update this.chart.data.datasets[0].data.push(...new_channels); this.chart.update('quiet'); } diff --git a/src/fprime_gds/flask/static/addons/chart-display/config.js b/src/fprime_gds/flask/static/addons/chart-display/config.js index 6ab40b8e..e42b9bac 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/config.js +++ b/src/fprime_gds/flask/static/addons/chart-display/config.js @@ -1,8 +1,14 @@ /** - * Configuration settings for the Chart JS plugin. + * config.js: + * + * Configuration settings for the Chart JS plugin. This is such that the configuration options are in an easy to set + * place in case adjustments need to be made. */ - +/** + * Basic chart options (high-level). + * @type {{responsive: boolean, interaction: {intersect: boolean}, parsing: boolean, maintainAspectRatio: boolean, animation: boolean}} + */ export let chart_options = { parsing: true, animation: false, @@ -13,20 +19,32 @@ export let chart_options = { }, }; +/** + * Data set specific configuration. + * @type {{spanGaps: boolean, backgroundColor: string, borderColor: string, normalized: boolean, lineTension: number}} + */ export let dataset_config = { - normalized: false, + normalized: true, spanGaps: false, backgroundColor: "rgba(54, 162, 235, 0.5)", borderColor: "rgb(54, 162, 235)", lineTension: 0, }; +/** + * Ticks configuration. Set to be minimal such that the chart renders more quickly. + * @type {{maxRotation: number, sampleSize: number, autoSkip: boolean}} + */ export let ticks_config = { autoSkip: true, maxRotation: 0, sampleSize: 10 }; +/** + * Realtime configuration options. Balances update efficiency vs visual efficiency and data set recall size. + * @type {{duration: number, frameRate: number, delay: number, refresh: number, ttl: number, pause: boolean}} + */ export let realtime_config = { // Initial display width (ms): 1 min duration: 60000, @@ -35,15 +53,17 @@ export let realtime_config = { // Initial chart delay (ms): 0 delay: 0, // Drawing framerate (ms): 30 Hz - frameRate: 30, + frameRate: 10, // Start paused: false pause: false, - // Refresh rate: 30 Hz - refresh: 100 + // Refresh rate: 10 Hz + refresh: 100 //In ms }; - - +/** + * Zoom settings conifguring a zoomable graph using SHIFT and ALT to pan/zoom. + * @type {{zoom: {mode: string, wheel: {modifierKey: string, enabled: boolean}, overScaleMode: string, drag: {modifierKey: string, enabled: boolean}}, pan: {mode: string, modifierKey: string, enabled: boolean}, limits: {x: {minDelay: number, maxDelay: *, minDuration: number, maxDuration: *}}}} + */ export let zoom_config = { // Allows pan using the "shift" modifier key pan: { @@ -77,6 +97,11 @@ export let zoom_config = { }, }; +/** + * Returns a new chart config object for the given labeled data set. + * @param label + * @return {{data: {datasets: [*]}, options: *, type: string}} + */ export function generate_chart_config(label) { let final_realtime_config = Object.assign({}, realtime_config); let scales = { diff --git a/src/fprime_gds/flask/static/addons/chart-display/sibling.js b/src/fprime_gds/flask/static/addons/chart-display/sibling.js index e5783638..f9b2a785 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/sibling.js +++ b/src/fprime_gds/flask/static/addons/chart-display/sibling.js @@ -1,4 +1,6 @@ /** + * sibling.js: + * * Contains the functions and definitions needed for handling sibling charts and the syncing between them. */ @@ -13,9 +15,13 @@ function syncSibling(leader, follower) { follower.zoomScale("x", {min: min, max: max}, "none"); } - +/** + * SiblingSet: + * + * A class containing a set of siblings that will be synchronized together. These siblings will share time and axis + * bounds when the lock axis is set. Note: a sibling is a Chart JS object. + */ export class SiblingSet { - constructor() { this.in_sync = false; this._siblings = []; @@ -26,6 +32,12 @@ export class SiblingSet { this.sync = this.get_guarded_function(this.__syncFromPrime); } + /** + * Provides a function guarded with an "in_sync" check. This ensures that the functions only run when we want the + * charts to be synchronized. + * @param func: function to produce a guarded variant of + * @return {Function}: function, but only run when not in sync. + */ get_guarded_function(func) { let _self = this; return (...args) => { @@ -35,14 +47,24 @@ export class SiblingSet { }; } + /** + * Synchronizes the supplied sibling to the primary sibling as returned by the prime() function of this class. + * @param sibling: sibling who will change to conform to the parent sibling + * @private + */ __syncFromPrime(sibling) { let prime = this.prime(); - if (sibling == null || prime == null || prime == sibling) { + if (sibling == null || prime == null || prime === sibling) { return; } syncSibling(this.prime(), sibling); } + /** + * Synchronize the given leader to all siblings. + * @param leader: leader to use as base for conforming others + * @private + */ __syncToAll(leader) { let syncing_function = syncSibling.bind(undefined, leader); if (leader == null) { @@ -51,27 +73,49 @@ export class SiblingSet { this._siblings.filter((item) => {return item !== leader}).map(syncing_function); } + /** + * Pause all siblings by setting their pause member variable. + * @param pause: true/false to pause or not pause. + * @private + */ __pauseAll(pause) { this._siblings.map((sibling) => {sibling.options.scales.x.realtime.pause = pause;}); } + /** + * Reset the zoom level of all siblings. + * @private + */ __resetAll() { this._siblings.map((sibling) => {sibling.resetZoom("none")}); } + /** + * Returns the prime sibling. This is the first of the siblings in the list, or null if no siblings exist. + * @return {null}: prime sibling or null + */ prime() { - return (this._siblings) ? this._siblings[0] : null; + return (this._siblings.length > 0) ? this._siblings[0] : null; } + /** + * Add a sibling to the set + * @param sibling: sibling to add + */ add(sibling) { if (this._siblings.indexOf(sibling) === -1) { this._siblings.push(sibling); } } + + /** + * Remove sibling from set + * @param sibling: sibling to remove + */ remove(sibling) { let index = this._siblings.indexOf(sibling); if (index !== -1) { - this._siblings.slice(index, 1); + this._siblings.slice(index, 1); // lgtm [js/ignore-array-result] } } } \ No newline at end of file diff --git a/src/fprime_gds/flask/static/js/vue-support/utils.js b/src/fprime_gds/flask/static/js/vue-support/utils.js index a96e8709..1bf16b1d 100644 --- a/src/fprime_gds/flask/static/js/vue-support/utils.js +++ b/src/fprime_gds/flask/static/js/vue-support/utils.js @@ -44,6 +44,16 @@ export function filter(items, matching, ifun) { return output; } +/** + * Get a date object from a given time. + * @param time: time object in fprime time format + * @return {Date}: Javascript date object + */ +function timeToDate(time) { + let date = new Date((time.seconds * 1000) + (time.microseconds/1000)); + return date; +} + /** * Convert a given F´ time into a string for display purposes. * @param time: f´ time to convert @@ -52,7 +62,7 @@ export function filter(items, matching, ifun) { export function timeToString(time) { // If we have a workstation time, convert it to calendar time if (time.base.value == 2) { - let date = new Date((time.seconds * 1000) + (time.microseconds/1000)); + let date = timeToDate(time); return date.toISOString(); } return time.seconds + "." + time.microseconds; From b3b8c3077070ca8b1ec0bb6bbbd277b987efd336 Mon Sep 17 00:00:00 2001 From: M Starch Date: Mon, 2 Aug 2021 15:38:46 -0700 Subject: [PATCH 4/5] lestarch: fixing GDS logs to load only when selected --- .github/actions/spelling/expect.txt | 3 ++ src/fprime_gds/flask/app.py | 7 ++- src/fprime_gds/flask/logs.py | 36 +++++++++++---- .../static/addons/chart-display/config.js | 2 +- .../static/addons/chart-display/sibling.js | 2 +- src/fprime_gds/flask/static/index.html | 22 --------- src/fprime_gds/flask/static/js/config.js | 5 +- src/fprime_gds/flask/static/js/datastore.js | 18 ++------ src/fprime_gds/flask/static/js/loader.js | 3 +- .../flask/static/js/vue-support/log.js | 46 ++++++++++++++++--- .../flask/static/js/vue-support/utils.js | 2 +- 11 files changed, 86 insertions(+), 60 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 0d657c39..852d9fed 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -213,6 +213,7 @@ FONTPATH FONTSIZE fprime fptable +framerate fromtimestamp frontend fsw @@ -567,6 +568,7 @@ thtcp thudp timebase timedelta +timescales timestep timestring timetype @@ -664,6 +666,7 @@ xcode xhtml xhttp xl +xy xlsx xml yaml diff --git a/src/fprime_gds/flask/app.py b/src/fprime_gds/flask/app.py index afa460f9..75439986 100644 --- a/src/fprime_gds/flask/app.py +++ b/src/fprime_gds/flask/app.py @@ -125,10 +125,15 @@ def construct_app(): # Optionally serve log files if app.config["SERVE_LOGS"]: api.add_resource( - fprime_gds.flask.logs.FlaskLogger, + fprime_gds.flask.logs.LogList, "/logdata", resource_class_args=[app.config["LOG_DIR"]], ) + api.add_resource( + fprime_gds.flask.logs.LogFile, + "/logdata/", + resource_class_args=[app.config["LOG_DIR"]], + ) return app, api diff --git a/src/fprime_gds/flask/logs.py b/src/fprime_gds/flask/logs.py index e4297fbe..ae43f3b0 100644 --- a/src/fprime_gds/flask/logs.py +++ b/src/fprime_gds/flask/logs.py @@ -1,12 +1,30 @@ #### -# +# Handles GDS logs in a lazy-loading way #### import os import flask_restful import flask_restful.reqparse -class FlaskLogger(flask_restful.Resource): +class LogList(flask_restful.Resource): + """ A list of log files as produced by the GDS. """ + + def __init__(self, logdir): + """ + Constructor used to setup the log directory. + + :param logdir: log directory to search for logs + """ + self.logdir = logdir + + def get(self): + """ Returns a list of log files that are available. """ + logs = {} + listing = os.listdir(self.logdir) + return {"logs": [name for name in listing if name.endswith(".log")]} + + +class LogFile(flask_restful.Resource): """ Command dictionary endpoint. Will return dictionary when hit with a GET. """ @@ -19,16 +37,14 @@ def __init__(self, logdir): """ self.logdir = logdir - def get(self): + def get(self, name): """ Returns the logdir. """ logs = {} - listing = os.listdir(self.logdir) - for path in [path for path in listing if path.endswith(".log")]: - full_path = os.path.join(self.logdir, path) - offset = 0 - with open(full_path) as file_handle: - file_handle.seek(offset) - logs[path] = file_handle.read() + full_path = os.path.join(self.logdir, name) + offset = 0 + with open(full_path) as file_handle: + file_handle.seek(offset) + logs[name] = file_handle.read() return logs diff --git a/src/fprime_gds/flask/static/addons/chart-display/config.js b/src/fprime_gds/flask/static/addons/chart-display/config.js index e42b9bac..ff53262c 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/config.js +++ b/src/fprime_gds/flask/static/addons/chart-display/config.js @@ -61,7 +61,7 @@ export let realtime_config = { }; /** - * Zoom settings conifguring a zoomable graph using SHIFT and ALT to pan/zoom. + * Zoom settings configuring a zoom enabled graph using SHIFT and ALT to pan/zoom. * @type {{zoom: {mode: string, wheel: {modifierKey: string, enabled: boolean}, overScaleMode: string, drag: {modifierKey: string, enabled: boolean}}, pan: {mode: string, modifierKey: string, enabled: boolean}, limits: {x: {minDelay: number, maxDelay: *, minDuration: number, maxDuration: *}}}} */ export let zoom_config = { diff --git a/src/fprime_gds/flask/static/addons/chart-display/sibling.js b/src/fprime_gds/flask/static/addons/chart-display/sibling.js index f9b2a785..a3099293 100644 --- a/src/fprime_gds/flask/static/addons/chart-display/sibling.js +++ b/src/fprime_gds/flask/static/addons/chart-display/sibling.js @@ -6,7 +6,7 @@ /** - * Syncronize the axis of the supplied leader to the supplied follower + * Synchronize the axis of the supplied leader to the supplied follower * @param leader: source of axis information * @param follower: destination of axis information */ diff --git a/src/fprime_gds/flask/static/index.html b/src/fprime_gds/flask/static/index.html index a03b2537..58b7e9df 100644 --- a/src/fprime_gds/flask/static/index.html +++ b/src/fprime_gds/flask/static/index.html @@ -124,29 +124,7 @@
- -