diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 32391007..1db2d3db 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -66,6 +66,7 @@ CFDG CFDP CFPD cgi +chartjs checkbox chrono classdocs @@ -95,6 +96,7 @@ csum csv ctime CTORS +ctx curateable curated curating @@ -329,6 +331,7 @@ logselect longdesc lowercased lstrip +luxon lxml MAINPAGE makedirs @@ -360,6 +363,7 @@ mycompany myfile mymodule myproject +nagix namespace nargs navbar @@ -457,6 +461,7 @@ readlines README readonly readthedocs +realtime recommonmark recv recvd @@ -471,6 +476,7 @@ reqparse rerendered restructuredtext returncode +rgb rgba riverbankcomputing Roboto @@ -478,6 +484,7 @@ rpaetz rst rtd rtf +saba scm scrollable scrollbar @@ -574,6 +581,7 @@ transcoding treeview tsn tstring +ttl tts twbs txz 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 new file mode 100644 index 00000000..370973e6 --- /dev/null +++ b/src/fprime_gds/flask/static/addons/chart-display/chart-addon.js @@ -0,0 +1,468 @@ +/** + * 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/css/fpstyle.css b/src/fprime_gds/flask/static/css/fpstyle.css index c3e1f724..30c81bee 100644 --- a/src/fprime_gds/flask/static/css/fpstyle.css +++ b/src/fprime_gds/flask/static/css/fpstyle.css @@ -173,3 +173,34 @@ table td:last-child { background-color: #28a745 !important; color: #fff !important; } + +/** +* Style related to Charts page +**/ +.fp-chart-btn-icon { + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #fff; +} + +.fp-chart-btn-text { + font-size: 1rem; + font-weight: 700; + line-height: 2; + color: #fff; +} + +.fp-resize-box { + resize: vertical; + overflow: auto; + padding: 1em; +} + +.fade-enter-active, .fade-leave-active { + transition: opacity .5s; +} + +.fade-enter, .fade-leave-to { + opacity: 0; +} \ No newline at end of file diff --git a/src/fprime_gds/flask/static/index.html b/src/fprime_gds/flask/static/index.html index d6ddff66..4bc6a5cc 100644 --- a/src/fprime_gds/flask/static/index.html +++ b/src/fprime_gds/flask/static/index.html @@ -30,6 +30,7 @@ + @@ -91,10 +92,17 @@ + + + + +