openc3-cosmos-init/plugins/packages/openc3-tool-common/src/components/Graph.vue
<!--
# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# Modified by OpenC3, Inc.
# All changes Copyright 2024, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.
-->
<template>
<div>
<v-card @click.native="$emit('click')">
<v-system-bar
:class="selectedGraphId === id ? 'active' : 'inactive'"
v-show="!hideSystemBar"
>
<div v-show="errors.length !== 0" class="mx-2">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="error-graph-icon" @click="errorDialog = true"
>mdi-alert</v-icon
>
</div>
</template>
<span>Errors</span>
</v-tooltip>
</div>
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon data-test="edit-graph-icon" @click="editGraph = true">
mdi-pencil
</v-icon>
</div>
</template>
<span> Edit </span>
</v-tooltip>
<v-spacer />
<span>{{ title }}</span>
<v-spacer />
<div v-show="expand">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="collapse-all"
v-show="calcFullSize"
@click="collapseAll"
>
mdi-arrow-collapse
</v-icon>
<v-icon
data-test="expand-all"
v-show="!calcFullSize"
@click="expandAll"
>
mdi-arrow-expand
</v-icon>
</div>
</template>
<span v-show="calcFullSize"> Collapse </span>
<span v-show="!calcFullSize"> Expand </span>
</v-tooltip>
</div>
<div v-show="expand">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="collapse-width"
v-show="fullWidth"
@click="collapseWidth"
>
mdi-arrow-collapse-horizontal
</v-icon>
<v-icon
data-test="expand-width"
v-show="!fullWidth"
@click="expandWidth"
>
mdi-arrow-expand-horizontal
</v-icon>
</div>
</template>
<span v-show="fullWidth"> Collapse Width </span>
<span v-show="!fullWidth"> Expand Width </span>
</v-tooltip>
</div>
<div v-show="expand">
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="collapse-height"
v-show="fullHeight"
@click="collapseHeight"
>
mdi-arrow-collapse-vertical
</v-icon>
<v-icon
data-test="expand-height"
v-show="!fullHeight"
@click="expandHeight"
>
mdi-arrow-expand-vertical
</v-icon>
</div>
</template>
<span v-show="fullHeight"> Collapse Height </span>
<span v-show="!fullHeight"> Expand Height </span>
</v-tooltip>
</div>
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="minimize-screen-icon"
@click="minMaxTransition"
v-show="expand"
>
mdi-window-minimize
</v-icon>
<v-icon
data-test="maximize-screen-icon"
@click="minMaxTransition"
v-show="!expand"
>
mdi-window-maximize
</v-icon>
</div>
</template>
<span v-show="expand"> Minimize </span>
<span v-show="!expand"> Maximize </span>
</v-tooltip>
<v-tooltip top>
<template v-slot:activator="{ on, attrs }">
<div v-on="on" v-bind="attrs">
<v-icon
data-test="close-graph-icon"
@click="$emit('close-graph')"
>
mdi-close-box
</v-icon>
</div>
</template>
<span> Close </span>
</v-tooltip>
</v-system-bar>
<v-expand-transition>
<div class="pa-1" id="chart" ref="chart" v-show="expand">
<div :id="`chart${id}`"></div>
<div id="betweenCharts"></div>
<div :id="`overview${id}`" v-show="showOverview"></div>
</div>
</v-expand-transition>
</v-card>
<!-- Edit graph dialog -->
<graph-edit-dialog
v-if="editGraph"
v-model="editGraph"
:title="title"
:legend-position="legendPosition"
:items="items"
:graph-min-y="graphMinY"
:graph-max-y="graphMaxY"
:lines="lines"
:colors="colors"
:start-date-time="graphStartDateTime"
:end-date-time="graphEndDateTime"
:time-zone="timeZone"
@remove="removeItems([$event])"
@ok="editGraphClose"
@cancel="editGraph = false"
/>
<!-- Error dialog -->
<v-dialog v-model="errorDialog" max-width="600">
<v-system-bar>
<v-spacer />
<span>Errors</span>
<v-spacer />
</v-system-bar>
<v-card class="pa-3">
<v-row dense>
<v-text-field
readonly
hide-details
v-model="title"
class="pb-2"
label="Graph Title"
/>
</v-row>
<v-row class="my-3">
<v-textarea readonly rows="8" :value="error" />
</v-row>
<v-row>
<v-btn block @click="clearErrors"> Clear </v-btn>
</v-row>
</v-card>
</v-dialog>
<!-- Edit right click context menu -->
<v-menu
v-if="editGraphMenu"
v-model="editGraphMenu"
:position-x="editGraphMenuX"
:position-y="editGraphMenuY"
absolute
offset-y
>
<v-list>
<v-list-item @click="editGraph = true">
<v-list-item-title style="cursor: pointer">
Edit {{ title }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<graph-edit-item-dialog
v-if="editItem"
v-model="editItem"
:colors="colors"
:item="selectedItem"
@changeColor="changeColor"
@changeLimits="changeLimits"
@cancel="editItem = false"
@close="closeEditItem"
/>
<!-- Edit Item right click context menu -->
<v-menu
v-if="itemMenu"
v-model="itemMenu"
:position-x="itemMenuX"
:position-y="itemMenuY"
absolute
offset-y
>
<v-list nav dense>
<v-subheader>
{{ selectedItem.targetName }}
{{ selectedItem.packetName }}
{{ selectedItem.itemName }}
</v-subheader>
<v-list-item @click="editItem = true">
<v-list-item-icon>
<v-icon>mdi-pencil</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> Edit </v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="clearData([selectedItem])">
<v-list-item-icon>
<v-icon>mdi-eraser</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> Clear </v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-list-item @click="removeItems([selectedItem])">
<v-list-item-icon>
<v-icon>mdi-delete</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title> Delete </v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<!-- Edit Legend right click context menu -->
<v-menu
v-if="legendMenu"
v-model="legendMenu"
:position-x="legendMenuX"
:position-y="legendMenuY"
absolute
offset-y
>
<v-list>
<v-list-item @click="moveLegend('top')">
<v-list-item-title style="cursor: pointer"
>Legend Top</v-list-item-title
>
</v-list-item>
<v-list-item @click="moveLegend('bottom')">
<v-list-item-title style="cursor: pointer"
>Legend Bottom</v-list-item-title
>
</v-list-item>
<v-list-item @click="moveLegend('left')">
<v-list-item-title style="cursor: pointer"
>Legend Left</v-list-item-title
>
</v-list-item>
<v-list-item @click="moveLegend('right')">
<v-list-item-title style="cursor: pointer"
>Legend RIght</v-list-item-title
>
</v-list-item>
</v-list>
</v-menu>
<tr class="u-series" ref="info">
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<v-icon v-bind="attrs" v-on="on" class="info-tooltip">
mdi-information-variant-circle
</v-icon>
</template>
<span>
Click item to toggle<br />
Right click to edit
</span>
</v-tooltip>
</tr>
</div>
</template>
<script>
import GraphEditDialog from './GraphEditDialog'
import GraphEditItemDialog from './GraphEditItemDialog'
import uPlot from 'uplot'
import bs from 'binary-search'
import Cable from '../services/cable.js'
import TimeFilters from '@openc3/tool-common/src/tools/base/util/timeFilters.js'
require('uplot/dist/uPlot.min.css')
export default {
components: {
GraphEditDialog,
GraphEditItemDialog,
},
props: {
id: {
type: Number,
required: true,
},
selectedGraphId: {
type: Number,
// Not required because we pass null
},
state: {
type: String,
required: true,
},
// start time in nanoseconds to start the graph
// this allows multiple graphs to be synchronized
startTime: {
type: Number,
},
secondsGraphed: {
type: Number,
required: true,
},
pointsSaved: {
type: Number,
required: true,
},
pointsGraphed: {
type: Number,
required: true,
},
refreshIntervalMs: {
type: Number,
default: 200,
},
hideSystemBar: {
type: Boolean,
default: false,
},
hideOverview: {
type: Boolean,
default: false,
},
sparkline: {
type: Boolean,
default: false,
},
initialItems: {
type: Array,
},
// These allow the parent to force a specific height and/or width
height: {
type: Number,
},
width: {
type: Number,
},
timeZone: {
type: String,
default: 'local',
},
},
mixins: [TimeFilters],
data() {
return {
lines: [],
active: true,
expand: true,
fullWidth: true,
fullHeight: true,
graph: null,
editGraph: false,
editGraphMenu: false,
editGraphMenuX: 0,
editGraphMenuY: 0,
editItem: false,
itemMenu: false,
itemMenuX: 0,
itemMenuY: 0,
legendMenu: false,
legendMenuX: 0,
legendMenuY: 0,
legendPosition: 'bottom',
selectedItem: null,
showOverview: !this.hideOverview,
title: '',
overview: null,
data: [[]],
dataChanged: false,
timeout: null,
graphMinY: '',
graphMaxY: '',
graphStartDateTime: null,
graphEndDateTime: null,
indexes: {},
items: this.initialItems || [],
limitsValues: [],
drawInterval: null,
zoomChart: false,
zoomOverview: false,
cable: new Cable(),
subscription: null,
needToUpdate: false,
errorDialog: false,
errors: [],
colorIndex: 0,
colors: [
// These are taken right from the Astro css definitions for
// --color-data-visualization-1 through 8
'#00c7cb',
'#938bdb',
'#4dacff',
'lime',
'darkorange',
'red',
'gold',
'hotpink',
'tan',
'cyan',
'maroon',
'blue',
'teal',
'purple',
'green',
'brown',
'lightblue',
'white',
'black',
],
}
},
computed: {
calcFullSize: function () {
return this.fullWidth || this.fullHeight
},
error: function () {
if (this.errorDialog && this.errors.length > 0) {
return JSON.stringify(this.errors, null, 4)
}
return null
},
},
created() {
this.title = `Graph ${this.id}`
for (const [index, item] of this.items.entries()) {
this.data.push([]) // initialize the empty data arrays
this.indexes[this.subscriptionKey(item)] = index + 1
if (item.color === undefined) {
item.color = this.colors[this.colorIndex]
}
this.colorIndex++
if (this.colorIndex === this.colors.length) {
this.colorIndex = 0
}
}
},
mounted() {
// This code allows for temporary pulling in a patched uPlot
// Also note you need to add 'async' before the mounted method for await
// const plugin = document.createElement('script')
// plugin.setAttribute(
// 'src',
// 'https://leeoniya.github.io/uPlot/dist/uPlot.iife.min.js'
// )
// plugin.async = true
// document.head.appendChild(plugin)
// await new Promise(r => setTimeout(r, 500)) // Allow the js to load
// TODO: This is demo / performance code of multiple items with many data points
// 10 items at 500,000 each renders immediately and uses 180MB in Chrome
// Refresh still works, chrome is sluggish but once you pause it is very performant
// 500,000 pts at 1Hz is 138.9hrs .. almost 6 days
//
// 10 items at 100,000 each is very performant ... 1,000,000 pts is Qt TlmGrapher default
// 100,000 pts at 1Hz is 27.8hrs
//
// 100,000 takes 40ms, Chrome uses 160MB
// this.data = []
// const dataPoints = 100000
// const items = 10
// let pts = new Array(dataPoints)
// let times = new Array(dataPoints)
// let time = 1589398007
// let series = [{}]
// for (let i = 0; i < dataPoints; i++) {
// times[i] = time
// pts[i] = i
// time += 1
// }
// this.data.push(times)
// for (let i = 0; i < items; i++) {
// this.data.push(pts.map(x => x + i))
// series.push({
// label: 'Item' + i,
// stroke: this.colors[i]
// })
// }
// NOTE: These are just initial settings ... actual series are added by this.graph.addSeries
const { chartSeries, overviewSeries } = this.items.reduce(
(seriesObj, item) => {
const commonProps = {
spanGaps: true,
}
seriesObj.chartSeries.push({
...commonProps,
item: item,
label: this.formatLabel(item),
stroke: (u, seriesIdx) => {
return this.items[seriesIdx - 1].color
},
width: 2,
value: (self, rawValue) => {
if (typeof rawValue === 'string' || isNaN(rawValue)) {
return 'NaN'
} else {
return rawValue == null ? '--' : rawValue.toFixed(3)
}
},
})
seriesObj.overviewSeries.push({
...commonProps,
})
return seriesObj
},
{ chartSeries: [], overviewSeries: [] },
)
let chartOpts = {}
if (this.sparkline) {
this.hideSystemBar = true
this.hideOverview = true
this.showOverview = false
chartOpts = {
width: this.width,
height: this.height,
pxAlign: false,
cursor: {
show: false,
},
select: {
show: false,
},
legend: {
show: false,
},
scales: {
x: {
time: false,
},
},
axes: [
{
show: false,
},
{
show: false,
},
],
series: [
{},
{
stroke: 'white', // TODO: Light / dark theme
},
],
}
this.graph = new uPlot(
chartOpts,
this.data,
document.getElementById(`chart${this.id}`),
)
} else {
// Uplot wants the real timezone name ('local' doesn't work)
let timeZoneName = Intl.DateTimeFormat().resolvedOptions().timeZone
if (this.timeZone && this.timeZone !== 'local') {
timeZoneName = this.timeZone
}
chartOpts = {
...this.getSize('chart'),
...this.getScales(),
...this.getAxes('chart'),
// series: series, // TODO: Uncomment with the performance code
plugins: [this.linesPlugin()],
tzDate: (ts) => uPlot.tzDate(new Date(ts * 1e3), timeZoneName),
series: [
{
label: 'Time',
value: (u, v) =>
// Convert the unix timestamp into a formatted date / time
v == null ? '--' : this.formatSeconds(v, this.timeZone),
},
...chartSeries,
],
cursor: {
drag: {
x: true,
y: false,
},
// Sync the cursor across graphs so mouseovers are synced
sync: {
key: 'openc3',
// setSeries links graphs so clicking an item to hide it also hides the other graph item
// setSeries: true,
},
bind: {
mouseup: (self, targ, handler) => {
return (e) => {
// Single click while paused will resume the graph
// This makes it possible to resume in TlmViewer widgets
if (this.state === 'pause' && self.select.width === 0) {
this.$emit('start')
}
handler(e)
}
},
},
},
hooks: {
setScale: [
(chart, key) => {
if (key === 'x' && !this.zoomOverview && this.overview) {
this.zoomChart = true
let left = Math.round(
this.overview.valToPos(chart.scales.x.min, 'x'),
)
let right = Math.round(
this.overview.valToPos(chart.scales.x.max, 'x'),
)
this.overview.setSelect({ left, width: right - left })
this.zoomChart = false
}
},
],
setSelect: [
(chart) => {
// Pause the graph while selecting a range to zoom
if (this.state === 'start' && chart.select.width > 0) {
this.$emit('pause')
}
},
],
ready: [
(u) => {
let canvas = u.root.querySelector('.u-over')
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault()
this.itemMenu = false
this.legendMenu = false
this.editGraphMenuX = e.clientX
this.editGraphMenuY = e.clientY
this.editGraphMenu = true
})
let legend = u.root.querySelector('.u-legend')
legend.addEventListener('contextmenu', (e) => {
e.preventDefault()
this.editGraphMenu = false
this.legendMenu = false
this.itemMenuX = e.clientX
this.itemMenuY = e.clientY
// Grab the closest series and then figure out which index it is
let seriesEl = e.target.closest('.u-series')
let seriesIdx = Array.prototype.slice
.call(legend.childNodes[0].childNodes)
.indexOf(seriesEl)
let series = u.series[seriesIdx]
if (series.item) {
this.selectedItem = series.item
this.itemMenu = true
} else {
this.itemMenu = false
this.legendMenuX = e.clientX
this.legendMenuY = e.clientY
this.legendMenu = true
}
})
// Append the info to the legend
legend.querySelector('tbody').appendChild(this.$refs.info)
},
],
},
}
this.graph = new uPlot(
chartOpts,
this.data,
document.getElementById(`chart${this.id}`),
)
const overviewOpts = {
...this.getSize('overview'),
...this.getScales(),
...this.getAxes('overview'),
// series: series, // TODO: Uncomment with the performance code
tzDate: (ts) => uPlot.tzDate(new Date(ts * 1e3), timeZoneName),
series: [...overviewSeries],
cursor: {
y: false,
drag: {
setScale: false,
x: true,
y: false,
},
},
legend: {
show: false,
},
hooks: {
setSelect: [
(chart) => {
if (!this.zoomChart) {
// Pause the graph while selecting an overview range to zoom
if (this.state === 'start' && chart.select.width > 0) {
this.$emit('pause')
}
this.zoomOverview = true
let min = chart.posToVal(chart.select.left, 'x')
let max = chart.posToVal(
chart.select.left + chart.select.width,
'x',
)
this.graph.setScale('x', { min, max })
this.zoomOverview = false
}
},
],
},
}
this.overview = new uPlot(
overviewOpts,
this.data,
document.getElementById(`overview${this.id}`),
)
this.moveLegend(this.legendPosition)
// Allow the charts to dynamically resize when the window resizes
window.addEventListener('resize', this.resize)
}
if (this.state !== 'stop') {
this.startGraph()
}
},
beforeDestroy: function () {
this.stopGraph()
this.cable.disconnect()
window.removeEventListener('resize', this.resize)
},
watch: {
state: function (newState, oldState) {
switch (newState) {
case 'start':
// Only subscribe if we were previously stopped
// If we were paused we do nothing ... see the data function
if (oldState === 'stop') {
this.startGraph()
}
break
// case 'pause': Nothing to do ... see the data function
case 'stop':
this.stopGraph()
break
}
},
data: function (newData, oldData) {
this.dataChanged = true
},
graphMinY: function (newVal, oldVal) {
let val = parseFloat(newVal)
if (!isNaN(val)) {
this.graphMinY = val
}
this.setGraphRange()
},
graphMaxY: function (newVal, oldVal) {
let val = parseFloat(newVal)
if (!isNaN(val)) {
this.graphMaxY = val
}
this.setGraphRange()
},
graphStartDateTime: function (newVal, oldVal) {
if (newVal && typeof newVal === 'string') {
this.graphStartDateTime =
this.parseDateTime(this.graphStartDateTime, this.timeZone) * 1_000_000
if (this.graphStartDateTime !== oldVal) {
this.needToUpdate = true
}
} else if (newVal === null && oldVal) {
// If they clear the start date time we need to update
this.graphStartDateTime = null
this.needToUpdate = true
}
},
graphEndDateTime: function (newVal, oldVal) {
if (newVal && typeof newVal === 'string') {
this.graphEndDateTime =
this.parseDateTime(this.graphEndDateTime, this.timeZone) * 1_000_000
if (this.graphEndDateTime !== oldVal) {
this.needToUpdate = true
}
} else if (newVal === null && oldVal) {
// If they clear the end date time we need to update
this.graphEndDateTime = null
this.needToUpdate = true
}
},
},
methods: {
startGraph: function () {
this.subscribe()
this.timeout = setTimeout(() => {
this.updateTimeout()
}, this.refreshIntervalMs)
},
stopGraph: function () {
if (this.subscription) {
this.subscription.unsubscribe()
this.subscription = null
}
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = null
}
},
updateTimeout: function () {
this.updateGraphData()
this.timeout = setTimeout(() => {
this.updateTimeout()
}, this.refreshIntervalMs)
},
updateGraphData: function () {
// Ignore changes to the data while we're paused
if (this.state === 'pause' || !this.dataChanged) {
return
}
this.graph.setData(this.data)
if (this.overview) {
this.overview.setData(this.data)
}
let max = this.data[0][this.data[0].length - 1]
let ptsMin = this.data[0][this.data[0].length - this.pointsGraphed]
let min = this.data[0][0]
if (min < max - this.secondsGraphed) {
min = max - this.secondsGraphed
}
if (ptsMin > min) {
min = ptsMin
}
this.graph.setScale('x', { min, max })
this.dataChanged = false
},
formatLabel(item) {
if (item.valueType === 'CONVERTED' && item.reduced === 'DECOM') {
return item.itemName
} else {
let description = ''
// Only display valueType if we're not CONVERTED
if (item.valueType !== 'CONVERTED') {
description += item.valueType
}
// Only display reduced if we're not DECOM
if (item.reduced !== 'DECOM') {
// If we already have the valueType add a space
if (description !== '') {
description += ' '
}
description += `${item.reduced.split('_')[1]} ${item.reducedType}`
}
return `${item.itemName} (${description})`
}
},
moveLegend: function (desired) {
switch (desired) {
case 'bottom':
this.graph.root.classList.remove('side-legend')
this.graph.root.classList.remove('left-legend')
this.graph.root.classList.remove('top-legend')
break
case 'top':
this.graph.root.classList.remove('side-legend')
this.graph.root.classList.remove('left-legend')
this.graph.root.classList.add('top-legend')
break
case 'left':
this.graph.root.classList.remove('top-legend')
this.graph.root.classList.add('side-legend')
this.graph.root.classList.add('left-legend')
break
case 'right':
this.graph.root.classList.remove('top-legend')
this.graph.root.classList.remove('left-legend')
this.graph.root.classList.add('side-legend')
break
}
this.legendPosition = desired
this.resize()
},
clearErrors: function () {
this.errors = []
},
editGraphClose: function (graph) {
this.editGraph = false
this.title = graph.title
// Don't need to copy items because we don't modify them
this.legendPosition = graph.legendPosition
this.graphMinY = graph.graphMinY
this.graphMaxY = graph.graphMaxY
this.lines = [...graph.lines]
this.graphStartDateTime = graph.startDateTime
this.graphEndDateTime = graph.endDateTime
// Allow the watch to update needToUpdate
this.$nextTick(() => {
if (this.needToUpdate) {
if (this.subscription == null) {
this.startGraph()
} else {
// NOTE: removing and adding back to back broke the streaming_api
// because the messages got out of order (add before remove)
// Code in openc3-cosmos-cmd-tlm-api/app/channels/application_cable/channel.rb
// fixed the issue to enforce ordering.
// Clone the items first because removeItems modifies this.items
let clonedItems = JSON.parse(JSON.stringify(this.items))
this.removeItems(clonedItems)
setTimeout(() => {
this.addItems(clonedItems)
}, 0)
}
this.needToUpdate = false
}
})
this.moveLegend(this.legendPosition)
this.$emit('edit')
},
resize: function () {
this.graph.setSize(this.getSize('chart'))
if (this.overview) {
this.overview.setSize(this.getSize('overview'))
}
this.$emit('resize', this.id)
},
expandAll: function () {
this.fullWidth = true
this.fullHeight = true
this.resize()
},
collapseAll: function () {
this.fullWidth = false
this.fullHeight = false
this.resize()
},
expandWidth: function () {
this.fullWidth = true
this.resize()
},
collapseWidth: function () {
this.fullWidth = false
this.resize()
},
expandHeight: function () {
this.fullHeight = true
this.resize()
},
collapseHeight: function () {
this.fullHeight = false
this.resize()
},
minMaxTransition: function () {
this.expand = !this.expand
this.$emit('min-max-graph', this.id)
},
setGraphRange: function () {
let pad = 0.1
if (
this.graphMinY ||
this.graphMinY === 0 ||
this.graphMaxY ||
this.graphMaxY === 0
) {
pad = 0
}
this.graph.scales.y.range = (u, dataMin, dataMax) => {
let min = dataMin
if (this.graphMinY || this.graphMinY === 0) {
min = this.graphMinY
}
let max = dataMax
if (this.graphMaxY || this.graphMaxY === 0) {
max = this.graphMaxY
}
return uPlot.rangeNum(min, max, pad, true)
}
},
subscribe: function () {
this.cable
.createSubscription('StreamingChannel', window.openc3Scope, {
received: (data) => this.received(data),
connected: () => {
this.addItemsToSubscription(this.items)
},
disconnected: () => {
this.errors.push({
type: 'disconnected',
message: 'OpenC3 backend connection disconnected',
time: new Date().getTime(),
})
},
rejected: () => {
this.errors.push({
type: 'rejected',
message: 'OpenC3 backend connection rejected',
time: new Date().getTime(),
})
},
})
.then((subscription) => {
this.subscription = subscription
})
},
// throttle(cb, limit) {
// var wait = false
// return () => {
// if (!wait) {
// requestAnimationFrame(cb)
// wait = true
// setTimeout(() => {
// wait = false
// }, limit)
// }
// }
// },
getSize: function (type) {
let navDrawerWidth = 0
const navDrawer = document.getElementById('openc3-nav-drawer')
if (navDrawer) {
navDrawerWidth = navDrawer.classList.contains(
'v-navigation-drawer--open',
)
? navDrawer.clientWidth
: 0
}
let legendWidth = 0
if (this.legendPosition === 'right' || this.legendPosition === 'left') {
const legend = document.getElementsByClassName('u-legend')[0]
legendWidth = legend.clientWidth
}
const viewWidth =
Math.max(document.documentElement.clientWidth, window.innerWidth || 0) -
navDrawerWidth -
legendWidth
const viewHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0,
)
const chooser = document.getElementsByClassName('expansion')[0]
let height = 100
if (type === 'overview') {
// Show overview if we're full height and we're not explicitly hiding it
if (this.fullHeight && !this.hideOverview) {
this.showOverview = true
} else {
this.showOverview = false
}
} else if (chooser) {
// Height of chart is viewportSize - chooser - overview - fudge factor (primarily padding)
height = viewHeight - chooser.clientHeight - height - 250
if (!this.fullHeight) {
height = height / 2.0 + 10 // 5px padding top and bottom
}
}
let width = viewWidth - 42 // padding left and right
if (!this.fullWidth) {
width = width / 2.0 - 10 // 5px padding left and right
}
return {
width: this.width || width,
height: this.height || height,
}
},
getScales: function () {
return {
scales: {
x: {
range(u, dataMin, dataMax) {
if (dataMin == null) return [1566453600, 1566497660]
return [dataMin, dataMax]
},
},
y: {
range(u, dataMin, dataMax) {
if (dataMin == null) return [-100, 100]
return uPlot.rangeNum(dataMin, dataMax, 0.1, true)
},
},
},
}
},
getAxes: function (type) {
let strokeColor = 'rgba(0, 0, 0, .1)'
let axisColor = 'black'
if (this.$vuetify.theme.dark) {
strokeColor = 'rgba(255, 255, 255, .1)'
axisColor = 'white'
}
return {
axes: [
{
stroke: axisColor,
grid: {
show: true,
stroke: strokeColor,
width: 2,
},
},
{
size: 80, // This size supports values up to 8 digits plus sign
stroke: axisColor,
grid: {
show: type === 'overview' ? false : true,
stroke: strokeColor,
width: 2,
},
// Forces the axis values to be formatted correctly
// especially with really small or large values
values(u, splits) {
if (
splits.some((el) => el >= 10_000_000) ||
splits.every((el) => el < 0.01)
) {
splits = splits.map((split) => split.toExponential(3))
}
return splits
},
},
],
}
},
closeEditItem: function (event) {
this.editItem = false
if (
// If we have an end time and anything was changed we basically regraph
(this.graphEndDateTime !== null && this.selectedItem !== event) ||
// If we're live graphing we just regraph if the types change
this.selectedItem.valueType !== event.valueType ||
this.selectedItem.reduced !== event.reduced ||
this.selectedItem.reducedType !== event.reducedType
) {
this.changeItem(event)
}
},
changeColor: function (event) {
let key = this.subscriptionKey(this.selectedItem)
let index = this.indexes[key]
this.items[index - 1].color = event
this.selectedItem.color = event
this.graph.root.querySelectorAll('.u-marker')[index].style.borderColor =
event
},
changeLimits: function (limits) {
let key = this.subscriptionKey(this.selectedItem)
let index = this.indexes[key]
this.items[index - 1].limits = limits
this.selectedItem.limits = limits
this.limitsValues = limits
},
linesPlugin: function () {
return {
hooks: {
draw: (u) => {
const { ctx, bbox } = u
// These are all in canvas units
const yMin = u.valToPos(u.scales.y.min, 'y', true)
const yMax = u.valToPos(u.scales.y.max, 'y', true)
const redLow = u.valToPos(this.limitsValues[0], 'y', true)
const yellowLow = u.valToPos(this.limitsValues[1], 'y', true)
const yellowHigh = u.valToPos(this.limitsValues[2], 'y', true)
const redHigh = u.valToPos(this.limitsValues[3], 'y', true)
let height = 0
// NOTE: These comparisons are tricky because the canvas
// starts in the upper left with 0,0. Thus it grows downward
// and to the right with increasing values. The comparisons
// of scale and limitsValues use graph coordinates but the
// fillRect calculations use the canvas coordinates.
// Draw Y axis lines
this.lines.forEach((line) => {
if (
u.scales.y.min <= line.yValue &&
line.yValue <= u.scales.y.max
) {
ctx.save()
ctx.beginPath()
ctx.strokeStyle = line.color
ctx.lineWidth = 2
ctx.moveTo(bbox.left, u.valToPos(line.yValue, 'y', true))
ctx.lineTo(
bbox.left + bbox.width,
u.valToPos(line.yValue, 'y', true),
)
ctx.stroke()
ctx.restore()
}
})
ctx.save()
ctx.beginPath()
// Draw red limits
ctx.fillStyle = 'rgba(255,0,0,0.15)'
if (u.scales.y.min < this.limitsValues[0]) {
let start = redLow < yMax ? yMax : redLow
ctx.fillRect(bbox.left, redLow, bbox.width, yMin - start)
}
if (u.scales.y.max > this.limitsValues[3]) {
let end = yMin < redHigh ? yMin : redHigh
ctx.fillRect(bbox.left, yMax, bbox.width, end - yMax)
}
// Draw yellow limits
ctx.fillStyle = 'rgba(255,255,0,0.15)'
if (
u.scales.y.min < this.limitsValues[1] && // yellowLow
u.scales.y.max > this.limitsValues[0] // redLow
) {
let start = yellowLow < yMax ? yMax : yellowLow
ctx.fillRect(bbox.left, start, bbox.width, redLow - start)
}
if (
u.scales.y.max > this.limitsValues[2] && // yellowHigh
u.scales.y.min < this.limitsValues[3] // redHigh
) {
let start = yMin < redHigh ? yMin : redHigh
let end = yMin < yellowHigh ? yMin : yellowHigh
ctx.fillRect(bbox.left, start, bbox.width, end - start)
}
// Draw green limits & operational limits
ctx.fillStyle = 'rgba(0,255,0,0.15)'
// If there are no operational limits the interior is all green
if (this.limitsValues.length === 4) {
// Determine if we show any green
if (
u.scales.y.min < this.limitsValues[2] && // yellowHigh
u.scales.y.max > this.limitsValues[1] // yellowLow
) {
let start = yellowHigh < yMax ? yMax : yellowHigh
let end = yMin < yellowLow ? yMin : yellowLow
ctx.fillRect(bbox.left, start, bbox.width, end - start)
}
} else {
// Operational limits
const greenLow = u.valToPos(this.limitsValues[4], 'y', true)
const greenHigh = u.valToPos(this.limitsValues[5], 'y', true)
if (
u.scales.y.min < this.limitsValues[4] && // greenLow
u.scales.y.max > this.limitsValues[1] // yellowLow
) {
let start = greenLow < yMax ? yMax : greenLow
ctx.fillRect(bbox.left, start, bbox.width, yellowLow - start)
}
if (
u.scales.y.max > this.limitsValues[5] && // greenHigh
u.scales.y.min < this.limitsValues[2] // yellowHigh
) {
let start = yMin < yellowHigh ? yMin : yellowHigh
let end = yMin < greenHigh ? yMin : greenHigh
ctx.fillRect(bbox.left, start, bbox.width, end - start)
}
ctx.fillStyle = 'rgba(0,0,255,0.15)'
let start = greenHigh < yMax ? yMax : greenHigh
let end = yMin < greenLow ? yMin : greenLow
ctx.fillRect(bbox.left, start, bbox.width, end - start)
}
ctx.stroke()
ctx.restore()
},
},
}
},
changeItem: function (event) {
// NOTE: removing and adding items back to back broke the streaming_api
// because the messages got out of order (add before remove)
// Code in openc3-cosmos-cmd-tlm-api/app/channels/application_cable/channel.rb
// fixed the issue to enforce ordering.
this.removeItems([this.selectedItem])
this.selectedItem.valueType = event.valueType
this.selectedItem.reduced = event.reduced
this.selectedItem.reducedType = event.reducedType
setTimeout(() => {
this.addItems([this.selectedItem])
}, 0)
},
addItems: function (itemArray, type = 'CONVERTED') {
for (const item of itemArray) {
item.valueType ||= type // set the default type
if (item.color === undefined) {
item.color = this.colors[this.colorIndex]
}
if (item.limits === undefined) {
// [] matches 'NONE' in GraphEditItemDialog
item.limits = []
} else {
if (item.limits.length > 0) {
// If somehow we have more than one limits
// the last one wins which is fine
this.limitsValues = item.limits
}
}
this.colorIndex++
if (this.colorIndex === this.colors.length) {
this.colorIndex = 0
}
this.items.push(item)
const index = this.data.length
this.graph.addSeries(
{
spanGaps: true,
item: item,
label: this.formatLabel(item),
stroke: (u, seriesIdx) => {
return this.items[seriesIdx - 1].color
},
width: 2,
value: (self, rawValue) => {
if (typeof rawValue === 'string' || isNaN(rawValue)) {
return 'NaN'
} else {
if (rawValue == null) {
return '--'
} else if (
(Math.abs(rawValue) < 0.01 && rawValue !== 0) ||
Math.abs(rawValue) >= 10_000_000
) {
return rawValue.toExponential(6)
} else {
return rawValue.toFixed(6)
}
}
},
},
index,
)
if (this.overview) {
this.overview.addSeries(
{
spanGaps: true,
stroke: (u, seriesIdx) => {
return this.items[seriesIdx - 1].color
},
},
index,
)
}
let newData = Array(this.data[0].length)
this.data.splice(index, 0, newData)
this.indexes[this.subscriptionKey(item)] = index
}
// Figure out the last item's color and set the colorIndex past that
let index = this.colors.indexOf(itemArray[itemArray.length - 1].color)
if (index) {
this.colorIndex = index + 1
}
this.addItemsToSubscription(itemArray)
this.$emit('resize')
this.$emit('edit')
},
addItemsToSubscription: function (itemArray = this.items) {
let theStartTime = this.startTime
if (this.graphStartDateTime) {
theStartTime = this.graphStartDateTime
}
if (this.subscription) {
OpenC3Auth.updateToken(OpenC3Auth.defaultMinValidity).then(
(refreshed) => {
if (refreshed) {
OpenC3Auth.setTokens()
}
this.subscription.perform('add', {
scope: window.openc3Scope,
token: localStorage.openc3Token,
items: itemArray.map(this.subscriptionKey),
start_time: theStartTime,
end_time: this.graphEndDateTime,
})
},
)
}
},
clearAllData: function () {
// Clear all data so delete the time data as well
this.data[0] = []
this.clearData(this.items)
},
clearData: function (itemArray) {
for (const key of itemArray.map(this.subscriptionKey)) {
let index = this.indexes[key]
this.data[index] = Array(this.data[0].length).fill(null)
this.graph.setData(this.data)
if (this.overview) {
this.overview.setData(this.data)
}
}
// data.length of 2 means we only have 1 item
// so delete all the time (data[0]) to start fresh
if (this.data.length === 2) {
this.data[0] = []
this.graph.setData(this.data)
this.overview.setData(this.data)
}
},
removeItems: function (itemArray) {
this.removeItemsFromSubscription(itemArray)
for (const key of itemArray.map(this.subscriptionKey)) {
const index = this.reorderIndexes(key)
this.items.splice(index - 1, 1)
this.data.splice(index, 1)
this.graph.delSeries(index)
this.graph.setData(this.data)
if (this.overview) {
this.overview.delSeries(index)
this.overview.setData(this.data)
}
}
// data.length of 1 means we've deleted all our items
// so delete all the time (data[0]) to start fresh
if (this.data.length === 1) {
this.data[0] = []
this.graph.setData(this.data)
this.overview.setData(this.data)
}
this.$emit('resize')
this.$emit('edit')
},
removeItemsFromSubscription: function (itemArray = this.items) {
if (this.subscription) {
this.subscription.perform('remove', {
scope: window.openc3Scope,
token: localStorage.openc3Token,
items: itemArray.map(this.subscriptionKey),
})
}
},
reorderIndexes: function (key) {
let index = this.indexes[key]
delete this.indexes[key]
for (var i in this.indexes) {
if (this.indexes[i] > index) {
this.indexes[i] -= 1
}
}
return index
},
received: function (data) {
this.cable.recordPing()
// TODO: Shouldn't get errors but should we handle this every time?
// if (json_data.error) {
// console.log(json_data.error)
// return
// }
for (let i = 0; i < data.length; i++) {
let time = data[i].__time / 1_000_000_000.0 // Time in seconds
let length = data[0].length
if (length === 0 || time > data[0][length - 1]) {
// Nominal case - append new data to end
for (let j = 0; j < this.data.length; j++) {
this.data[j].push(null)
}
this.set_data_at_index(this.data[0].length - 1, time, data[i])
} else {
let index = bs(this.data[0], time, this.bs_comparator)
if (index >= 0) {
// Found the slot in the existing data
this.set_data_at_index(index, time, data[i])
} else {
// Insert a new null slot at the ideal index
let ideal_index = -index - 1
for (let j = 0; j < this.data.length; j++) {
this.data[j].splice(ideal_index, 0, null)
}
this.set_data_at_index(ideal_index, time, data[i])
}
}
}
// If we weren't passed a startTime notify grapher of our start
if (this.startTime == null && this.data[0][0]) {
let newStartTime = this.data[0][0] * 1_000_000_000
this.$emit('started', newStartTime)
}
this.dataChanged = true
},
bs_comparator: function (element, needle) {
return element - needle
},
set_data_at_index: function (index, time, new_data) {
this.data[0][index] = time
for (const [key, value] of Object.entries(new_data)) {
if (key === 'time') {
continue
}
let key_index = this.indexes[key]
if (key_index) {
let array = this.data[key_index]
// NaN and Infinite values are sent as objects with raw attribute set
// to 'NaN', '-Infinity', or 'Infinity', just set data to null
if (value?.raw) {
array[index] = null
} else if (typeof value === 'string') {
// Can't graph strings so just set to null
array[index] = null
// If it's not already RAW, change the type to RAW
// NOTE: Some items are RAW strings so they won't ever work
if (!key.includes('__RAW')) {
for (let item of this.items) {
if (this.subscriptionKey(item) === key) {
this.selectedItem = item
break
}
}
this.changeItem({
valueType: 'RAW',
reduced: this.selectedItem.reduced,
reducedType: this.selectedItem.reducedType,
})
}
} else {
array[index] = value
}
}
}
},
subscriptionKey: function (item) {
let key = `${item.reduced}__TLM__${item.targetName}__${item.packetName}__${item.itemName}__${item.valueType}`
if (
item.reduced === 'REDUCED_MINUTE' ||
item.reduced === 'REDUCED_HOUR' ||
item.reduced === 'REDUCED_DAY'
) {
key += `__${item.reducedType}`
}
return key
},
},
}
</script>
<style>
.v-window-item {
background-color: var(--color-background-surface-default);
}
/* left right stacked legend */
.uplot.side-legend {
display: flex;
width: auto;
}
.uplot.side-legend .u-wrap {
flex: none;
}
.uplot.side-legend .u-legend {
text-align: left;
margin-left: 0;
width: 220px;
}
.uplot.side-legend .u-legend,
.uplot.side-legend .u-legend tr,
.uplot.side-legend .u-legend th,
.uplot.side-legend .u-legend td {
display: revert;
}
/* left side we need to order the legend before the plot */
.uplot.left-legend .u-legend {
order: -1;
}
/* top legend */
.uplot.top-legend {
display: flex;
flex-direction: column;
}
.uplot.top-legend .u-legend {
order: -1;
}
/* This value is large enough to support negative scientific notation
that we use on the value with rawValue.toExponential(6) */
.u-legend.u-inline .u-series .u-value {
width: 105px;
}
/* This value is large enough to support our date format: YYYY-MM-DD HH:MM:SS.sss */
.u-legend.u-inline .u-series:first-child .u-value {
width: 185px;
}
</style>
<style scoped>
/* TODO: Get this to work with white theme, values would be 0 in white */
#chart :deep(.u-select) {
background-color: rgba(255, 255, 255, 0.07);
}
/* This prevents the axis from responding to pointer-events.
Necessary if we set overview height to 0 which makes it hidden but still present.
However, we simply don't display the overview with v-show.
See https://github.com/leeoniya/uPlot/issues/689
#chart :deep(.u-axis) {
pointer-events: none;
} */
</style>