openc3-cosmos-init/plugins/packages/openc3-cosmos-tool-tlmgrapher/src/tools/TlmGrapher/TlmGrapher.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>
<top-bar :menus="menus" :title="title" />
<v-expansion-panels v-model="panel" class="expansion">
<v-expansion-panel>
<v-expansion-panel-header style="z-index: 1"></v-expansion-panel-header>
<v-expansion-panel-content>
<div v-show="this.selectedGraphId === null">
<v-row class="my-5">
<v-spacer />
<span>
Add a graph from the menu bar or select an existing graph to
continue
</span>
<v-spacer />
</v-row>
</div>
<v-row
v-show="this.selectedGraphId !== null"
class="ma-1"
style="flex-wrap: nowrap"
>
<target-packet-item-chooser
:initial-target-name="this.$route.params.target"
:initial-packet-name="this.$route.params.packet"
:initial-item-name="this.$route.params.item"
@click="addItem"
button-text="Add Item"
choose-item
select-types
/>
<div class="grapher-info">
<v-btn
v-show="state === 'pause'"
class="pulse control-button"
v-on:click="
() => {
state = 'start'
}
"
color="primary"
fab
data-test="start-graph"
>
<v-icon large>mdi-play</v-icon>
</v-btn>
<v-btn
v-show="state === 'start'"
class="control-button"
v-on:click="
() => {
state = 'pause'
}
"
color="primary"
fab
data-test="pause-graph"
>
<v-icon large>mdi-pause</v-icon>
</v-btn>
</div>
</v-row>
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<div>
<div class="grid">
<div
class="item"
v-for="graph in graphs"
:key="graph"
:id="`gridItem${graph}`"
:ref="`gridItem${graph}`"
>
<div class="item-content">
<graph
:ref="`graph${graph}`"
:id="graph"
:state="state"
:start-time="startTime"
:selected-graph-id="selectedGraphId"
:seconds-graphed="settings.secondsGraphed.value"
:points-saved="settings.pointsSaved.value"
:points-graphed="settings.pointsGraphed.value"
:refresh-interval-ms="settings.refreshIntervalMs.value"
:time-zone="timeZone"
@close-graph="() => closeGraph(graph)"
@min-max-graph="() => minMaxGraph(graph)"
@resize="() => resize()"
@pause="() => (state = 'pause')"
@start="() => (state = 'start')"
@click="() => graphSelected(graph)"
@edit="saveDefaultConfig(currentConfig)"
@started="graphStarted"
/>
</div>
</div>
</div>
</div>
<!-- Note we're using v-if here so it gets re-created each time and refreshes the list -->
<open-config-dialog
v-if="showOpenConfig"
v-model="showOpenConfig"
:configKey="configKey"
@success="openConfiguration"
/>
<!-- Note we're using v-if here so it gets re-created each time and refreshes the list -->
<save-config-dialog
v-if="showSaveConfig"
v-model="showSaveConfig"
:configKey="configKey"
@success="saveConfiguration"
/>
<!-- Note we're using v-if here so it gets re-created each time and refreshes the list -->
<settings-dialog
v-show="showSettingsDialog"
v-model="showSettingsDialog"
:settings="settings"
/>
</div>
</template>
<script>
import { OpenC3Api } from '@openc3/tool-common/src/services/openc3-api'
import Config from '@openc3/tool-common/src/components/config/Config'
import Graph from '@openc3/tool-common/src/components/Graph.vue'
import OpenConfigDialog from '@openc3/tool-common/src/components/config/OpenConfigDialog'
import SaveConfigDialog from '@openc3/tool-common/src/components/config/SaveConfigDialog'
import TargetPacketItemChooser from '@openc3/tool-common/src/components/TargetPacketItemChooser'
import TopBar from '@openc3/tool-common/src/components/TopBar'
import Muuri from 'muuri'
import SettingsDialog from '@/tools/TlmGrapher/SettingsDialog'
const MUURI_REFRESH_TIME = 250
export default {
components: {
Graph,
OpenConfigDialog,
SaveConfigDialog,
SettingsDialog,
TargetPacketItemChooser,
TopBar,
},
mixins: [Config],
data() {
return {
title: 'Telemetry Grapher',
configKey: 'telemetry_grapher',
showOpenConfig: false,
showSaveConfig: false,
showSettingsDialog: false,
api: null,
timeZone: null, // deliberately null so we know when it is set
grid: null,
panel: 0,
state: 'stop', // Valid: stop, start, pause
startTime: null, // Start time in nanoseconds
// Setup defaults to show an initial graph
graphs: [0],
selectedGraphId: 0,
counter: 1,
applyingConfig: false,
menus: [
{
label: 'File',
items: [
{
label: 'Open Configuration',
icon: 'mdi-folder-open',
command: () => {
this.showOpenConfig = true
},
},
{
label: 'Save Configuration',
icon: 'mdi-content-save',
command: () => {
this.showSaveConfig = true
},
},
{
label: 'Reset Configuration',
icon: 'mdi-monitor-shimmer',
command: () => {
this.panel = 0 // Expand the expansion panel
this.closeAllGraphs()
this.resetConfigBase()
},
},
],
},
{
label: 'Graph',
items: [
{
label: 'Add Graph',
icon: 'mdi-plus',
command: () => {
this.addGraph()
},
},
{
divider: true,
},
{
label: 'Start / Resume',
icon: 'mdi-play',
command: () => {
this.state = 'start'
},
},
{
label: 'Pause',
icon: 'mdi-pause',
command: () => {
this.state = 'pause'
},
},
{
label: 'Stop',
icon: 'mdi-stop',
command: () => {
this.state = 'stop'
},
},
{
divider: true,
},
{
label: 'Clear All Data',
icon: 'mdi-eraser',
command: () => {
this.clearAllData()
},
},
{
label: 'Settings',
icon: 'mdi-cog',
command: () => {
this.showSettingsDialog = true
},
},
],
},
],
settings: {
secondsGraphed: {
title: 'Seconds Graphed',
value: 1000,
rules: [(value) => !!value || 'Required'],
},
pointsSaved: {
title: 'Points Saved',
value: 1000000,
rules: [(value) => !!value || 'Required'],
},
pointsGraphed: {
title: 'Points Graphed',
value: 1000,
rules: [(value) => !!value || 'Required'],
},
refreshIntervalMs: {
title: 'Refresh Interval Ms',
value: 100,
rules: [(value) => !!value || 'Required'],
},
},
}
},
watch: {
settings: {
handler: function () {
this.saveDefaultConfig(this.currentConfig)
},
deep: true,
},
panel: function () {
this.resizeAll()
},
},
computed: {
currentConfig: function () {
return {
settings: {
secondsGraphed: this.settings.secondsGraphed.value,
pointsSaved: this.settings.pointsSaved.value,
pointsGraphed: this.settings.pointsGraphed.value,
refreshIntervalMs: this.settings.refreshIntervalMs.value,
},
graphs: this.grid.getItems().map((item) => {
// Map the gridItem id to the graph id
const graphId = `graph${item.getElement().id.substring(8)}`
const vueGraph = this.$refs[graphId][0]
let config = {
items: vueGraph.items,
title: vueGraph.title,
fullWidth: vueGraph.fullWidth,
fullHeight: vueGraph.fullHeight,
graphMinX: vueGraph.graphMinX,
graphMaxX: vueGraph.graphMaxX,
legendPosition: vueGraph.legendPosition,
lines: vueGraph.lines,
}
// Only add the start and end time if we have both
// This prevents adding just the start time and having the graph
// try to pull a LOT of data from some previously set date / time
if (vueGraph.graphStartDateTime && vueGraph.graphEndDateTime) {
config.graphStartDateTime = vueGraph.graphStartDateTime
config.graphEndDateTime = vueGraph.graphEndDateTime
}
return config
}),
}
},
},
created() {
this.api = new OpenC3Api()
this.api
.get_setting('time_zone')
.then((response) => {
if (response) {
this.timeZone = response
}
})
.catch((error) => {
// Do nothing
})
},
mounted: function () {
this.grid = new Muuri('.grid', {
dragEnabled: true,
layoutOnResize: true,
// Only allow drags starting from the v-system-bar title
dragHandle: '.v-system-bar',
})
// Sometimes when we move graphs, other graphs become non-interactive
// This seems to fix that issue
this.grid.on('move', function (data) {
data.item.getGrid().synchronize()
})
// Called like /tools/tlmgrapher?config=temps
if (this.$route.query && this.$route.query.config) {
this.openConfiguration(this.$route.query.config, true) // routed
}
// If we're passed in the route then manually addItem
else if (
this.$route.params.target &&
this.$route.params.packet &&
this.$route.params.item
) {
this.addItem({
targetName: this.$route.params.target.toUpperCase(),
packetName: this.$route.params.packet.toUpperCase(),
itemName: this.$route.params.item.toUpperCase(),
valueType: 'CONVERTED',
reduced: 'DECOM',
})
} else {
let config = this.loadDefaultConfig()
// Only apply the config if it's not an empty object (config does not exist)
if (JSON.stringify(config) !== '{}') {
this.applyConfig(config)
}
}
},
methods: {
resizeAll: function () {
setTimeout(() => {
this.grid.getItems().map((item) => {
// Map the gridItem id to the graph id
const graphId = `graph${item.getElement().id.substring(8)}`
const vueGraph = this.$refs[graphId][0]
vueGraph.resize()
})
this.resize()
}, MUURI_REFRESH_TIME)
},
graphSelected: function (id) {
this.selectedGraphId = id
},
addItem: function (newItem, startGraphing = true) {
for (const item of this.$refs[`graph${this.selectedGraphId}`][0].items) {
if (
newItem.targetName === item.targetName &&
newItem.packetName === item.packetName &&
newItem.itemName === item.itemName &&
newItem.valueType === item.valueType &&
newItem.reduced === item.reduced &&
newItem.reducedType === item.reducedType
) {
this.$notify.caution({
title: 'Item Already Exists',
body:
`Item ${newItem.targetName} ${newItem.packetName} ${newItem.itemName} ` +
`with ${newItem.valueType} ${newItem.reduced} ${newItem.reducedType} already exists!`,
})
return
}
}
this.$refs[`graph${this.selectedGraphId}`][0].addItems([newItem])
if (startGraphing === true) {
this.state = 'start'
}
this.saveDefaultConfig(this.currentConfig)
},
addGraph: function (checkExisting = true) {
const id = this.counter
let halfWidth = false
let halfHeight = false
// If there are existing graphs figure out how the first one looks
if (checkExisting && this.graphs.length != 0) {
if (this.$refs[`graph${this.graphs[0]}`][0].fullWidth === false) {
halfWidth = true
}
if (this.$refs[`graph${this.graphs[0]}`][0].fullHeight === false) {
halfHeight = true
}
}
this.graphs.push(id)
this.counter += 1
this.$nextTick(function () {
var items = this.grid.add(this.$refs[`gridItem${id}`], {
active: false,
})
this.grid.show(items)
this.selectedGraphId = id
// Make the new graph match the first one
if (halfWidth) {
this.$refs[`graph${id}`][0].collapseWidth()
}
if (halfHeight) {
this.$refs[`graph${id}`][0].collapseHeight()
}
setTimeout(() => {
this.grid.refreshItems().layout()
}, MUURI_REFRESH_TIME)
})
this.saveDefaultConfig(this.currentConfig)
},
closeGraph: function (id) {
var items = this.grid.getItems([document.getElementById(`gridItem${id}`)])
this.grid.remove(items)
this.graphs.splice(this.graphs.indexOf(id), 1)
// Clear out the startTime if we close all the graphs ... we're starting over
if (this.graphs.length === 0) {
this.startTime = null
this.selectedGraphId = null
} else {
this.selectedGraphId = this.graphs[0]
}
this.saveDefaultConfig(this.currentConfig)
},
closeAllGraphs: function () {
// Make a copy of this.graphs to iterate on since closeGraph modifies in place
for (let graph of [...this.graphs]) {
this.closeGraph(graph)
}
this.counter = 0
},
clearAllData: function () {
for (let graph of this.graphs) {
this.$refs[`graph${graph}`][0].clearAllData()
}
},
minMaxGraph: function (id) {
this.selectedGraphId = id
setTimeout(
() => {
this.grid.refreshItems().layout()
},
MUURI_REFRESH_TIME * 2, // Double the time since there is more animation
)
this.saveDefaultConfig(this.currentConfig)
},
resize: function () {
// Calculate the largest height of the legend elements and apply it to all
const elements = document.querySelectorAll('.u-legend')
let maxHeight = 0
elements.forEach((element) => {
element.style.height = ''
if (element.clientHeight > maxHeight) {
maxHeight = element.clientHeight
}
})
if (maxHeight !== 0) {
elements.forEach((element) => {
if (element.clientHeight !== maxHeight) {
element.style.height = `${maxHeight}px`
}
})
}
setTimeout(
() => {
this.grid.refreshItems().layout()
},
MUURI_REFRESH_TIME * 2, // Double the time since there is more animation
)
},
graphStarted: function (time) {
// Only set startTime once when notified by the first graph to start
// This allows us to have a uniform start time on all graphs
if (this.startTime === null) {
this.startTime = time
}
},
applyConfig: async function (config) {
// Grab the timeZone if we haven't already
// This ensures we're waiting for it and pass it to the graphs
if (this.timeZone === null) {
await this.api
.get_setting('time_zone')
.then((response) => {
if (response) {
this.timeZone = response
}
})
.catch((error) => {
// Do nothing
})
}
// Don't save the default config while we're applying new config
this.dontSaveDefaultConfig = true
this.closeAllGraphs()
await this.$nextTick()
this.settings.secondsGraphed.value = config.settings.secondsGraphed
this.settings.pointsSaved.value = config.settings.pointsSaved
this.settings.pointsGraphed.value = config.settings.pointsGraphed
this.settings.refreshIntervalMs.value = config.settings.refreshIntervalMs
let graphs = config.graphs
for (let graph of graphs) {
this.addGraph(false) // Don't check existing graphs
}
await this.$nextTick()
const that = this
graphs.forEach(function (graph, i) {
let vueGraph = that.$refs[`graph${i}`][0]
vueGraph.title = graph.title
vueGraph.fullWidth = graph.fullWidth
vueGraph.fullHeight = graph.fullHeight
vueGraph.graphMinX = graph.graphMinX
vueGraph.graphMaxX = graph.graphMaxX
vueGraph.graphStartDateTime = graph.graphStartDateTime
vueGraph.graphEndDateTime = graph.graphEndDateTime
vueGraph.moveLegend(graph.legendPosition)
vueGraph.addItems([...graph.items])
vueGraph.lines = graph.lines
})
this.state = 'start'
this.dontSaveDefaultConfig = false
},
openConfiguration: function (name, routed = false) {
this.openConfigBase(name, routed, async (config) => {
await this.applyConfig(config)
this.saveDefaultConfig(config)
})
this.panel = null // Minimize the expansion panel
},
saveConfiguration: function (name) {
this.saveConfigBase(name, this.currentConfig)
},
},
}
</script>
<style>
/* Flash the chevron icon 3 times to let the user know they can minimize the controls */
i.v-icon.mdi-chevron-down {
animation: pulse 2s 3;
}
@keyframes pulse {
0% {
-webkit-box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4);
}
70% {
-webkit-box-shadow: 0 0 0 10px rgba(255, 255, 255, 0);
}
100% {
-webkit-box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}
</style>
<style lang="scss" scoped>
.expansion {
padding: 5px;
background-color: var(--color-background-base-default) !important;
padding-bottom: 10px;
}
.grapher-info {
position: relative;
margin-top: 60px;
left: -120px;
height: 50px;
}
.control-button {
margin-right: 10px;
}
.v-expansion-panel-content {
.container {
margin: 0px;
}
}
.v-expansion-panel-header {
min-height: 10px;
padding: 5px;
}
.v-navigation-drawer {
z-index: 2;
}
.grid {
position: relative;
}
.item {
position: absolute;
z-index: 1;
}
.item.muuri-item-dragging {
z-index: 3;
}
.item.muuri-item-releasing {
z-index: 2;
}
.item.muuri-item-hidden {
z-index: 0;
}
.item-content {
position: relative;
cursor: pointer;
border-radius: 6px;
margin: 5px;
}
.pulse {
animation: pulse 1s infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>