openmct/src/plugins/plot/MctPlot.vue
Shefali Joshi ecd120387c
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Independent time conductor related handling for plot synchronization. (#7956)
* Ensure that the mode set when independent time conductor is enabled/disabled is propagated correctly.
Also ensure that global time conductor changes are not picked up by the independent time conductor when the user has enabled it at least once before

* Use structuredClone instead of deep copy

* Add e2e test

* Assert that you're in fixed mode after sync time conductor

* Comment explaining new time context test

* Change test to be a little less complicated

* Fix linting errors
2025-02-10 21:46:00 +00:00

1906 lines
59 KiB
Vue

<!--
Open MCT, Copyright (c) 2014-2024, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<div
v-if="loaded"
ref="plot"
class="gl-plot"
:class="{ 'js-series-data-loaded': seriesDataLoaded }"
>
<slot></slot>
<div class="plot-wrapper-axis-and-display-area flex-elem grows">
<div v-if="seriesModels.length" class="u-contents">
<YAxis
v-for="(yAxis, index) in yAxesIds"
:id="yAxis.id"
:key="`yAxis-${yAxis.id}-${index}`"
:position="yAxis.id > 2 ? 'right' : 'left'"
:class="{ 'plot-yaxis-right': yAxis.id > 2 }"
@y-key-changed="setYAxisKey"
@toggle-axis-visibility="toggleSeriesForYAxis"
/>
</div>
<div class="gl-plot-wrapper-display-area-and-x-axis" :style="xAxisStyle">
<div class="gl-plot-display-area has-local-controls has-cursor-guides">
<div class="l-state-indicators">
<span
class="l-state-indicators__alert-no-lad t-object-alert t-alert-unsynced icon-alert-triangle"
title="This plot is not currently displaying the latest data. Reset pan/zoom to view latest data."
></span>
</div>
<MctTicks
v-show="gridLines && !options.compact"
:axis-type="'xAxis'"
:position="'right'"
/>
<MctTicks
v-for="(yAxis, index) in yAxesIds"
v-show="gridLines"
:key="`yAxis-gridlines-${index}`"
:axis-type="'yAxis'"
:position="'bottom'"
:axis-id="yAxis.id"
/>
<div
ref="chartContainer"
class="gl-plot-chart-wrapper"
:class="[{ 'alt-pressed': altPressed }]"
>
<MctChart
:rectangles="rectangles"
:highlights="highlights"
:show-limit-line-labels="limitLineLabels"
:annotated-points-by-series="annotatedPointsBySeries"
:annotation-selections-by-series="annotationSelectionsBySeries"
:hidden-y-axis-ids="hiddenYAxisIds"
:annotation-viewing-and-editing-allowed="annotationViewingAndEditingAllowed"
@plot-reinitialize-canvas="initCanvas"
@chart-loaded="initialize"
/>
</div>
<div
class="gl-plot__local-controls h-local-controls h-local-controls--overlay-content c-local-controls--show-on-hover"
>
<div v-if="!options.compact" class="c-button-set c-button-set--strip-h js-zoom">
<button
class="c-button icon-minus"
title="Zoom out"
aria-label="Zoom out"
@click="zoom('out', 0.2)"
></button>
<button
class="c-button icon-plus"
title="Zoom in"
aria-label="Zoom in"
@click="zoom('in', 0.2)"
></button>
</div>
<div
v-if="plotHistory.length && !options.compact"
class="c-button-set c-button-set--strip-h js-pan"
>
<button
class="c-button icon-arrow-left"
title="Restore previous pan and zoom"
aria-label="Restore previous pan and zoom"
@click="back()"
></button>
<button
class="c-button icon-reset"
title="Reset pan and zoom"
aria-label="Reset pan and zoom"
@click="resumeRealtimeData()"
></button>
</div>
<div
v-if="isRealTime && !options.compact"
class="c-button-set c-button-set--strip-h js-pause"
>
<button
v-if="!isFrozen"
class="c-button icon-pause"
title="Pause incoming real-time data"
aria-label="Pause incoming real-time data"
@click="pause()"
></button>
<button
v-if="isFrozen"
class="c-button icon-arrow-right pause-play is-paused"
title="Resume displaying real-time data"
aria-label="Resume displaying real-time data"
@click="resumeRealtimeData()"
></button>
</div>
<div v-if="isTimeOutOfSync || isFrozen" class="c-button-set c-button-set--strip-h">
<button
class="c-button icon-clock"
title="Synchronize Time Conductor"
aria-label="Synchronize Time Conductor"
@click="showSynchronizeDialog()"
></button>
</div>
<div class="c-button-set c-button-set--strip-h">
<button
class="c-button icon-crosshair"
:class="{ 'is-active': cursorGuide }"
title="Toggle cursor guides"
aria-label="Toggle cursor guides"
@click="toggleCursorGuide"
></button>
<button
class="c-button"
:class="{ 'icon-grid-on': gridLines, 'icon-grid-off': !gridLines }"
title="Toggle grid lines"
aria-label="Toggle grid lines"
@click="toggleGridLines"
></button>
</div>
</div>
<!--Cursor guides-->
<div
v-show="cursorGuide"
ref="cursorGuideVertical"
aria-label="Vertical cursor guide"
class="c-cursor-guide--v js-cursor-guide--v"
></div>
<div
v-show="cursorGuide"
ref="cursorGuideHorizontal"
aria-label="Horizontal cursor guide"
class="c-cursor-guide--h js-cursor-guide--h"
></div>
</div>
<XAxis v-if="seriesModels.length > 0 && !options.compact" :series-model="seriesModels[0]" />
</div>
</div>
</div>
</template>
<script>
import Flatbush from 'flatbush';
import _ from 'lodash';
import { useEventBus } from 'utils/useEventBus';
import { inject, toRaw } from 'vue';
import { MODES } from '../../api/time/constants';
import { useAlignment } from '../../ui/composables/alignmentContext.js';
import TagEditorClassNames from '../inspectorViews/annotations/tags/TagEditorClassNames.js';
import XAxis from './axis/XAxis.vue';
import YAxis from './axis/YAxis.vue';
import MctChart from './chart/MctChart.vue';
import configStore from './configuration/ConfigStore.js';
import PlotConfigurationModel from './configuration/PlotConfigurationModel.js';
import eventHelpers from './lib/eventHelpers.js';
import LinearScale from './LinearScale.js';
import MctTicks from './MctTicks.vue';
const OFFSET_THRESHOLD = 10;
const AXES_PADDING = 20;
export default {
components: {
XAxis,
YAxis,
MctTicks,
MctChart
},
inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
props: {
options: {
type: Object,
default() {
return {
compact: false
};
}
},
initGridLines: {
type: Boolean,
default() {
return true;
}
},
initCursorGuide: {
type: Boolean,
default() {
return false;
}
},
limitLineLabels: {
type: Object,
default() {
return undefined;
}
},
colorPalette: {
type: Object,
default() {
return undefined;
}
}
},
emits: [
'config-loaded',
'cursor-guide',
'grid-lines',
'loading-complete',
'loading-updated',
'highlights',
'lock-highlight-point',
'status-updated'
],
setup() {
const { EventBus } = useEventBus();
const domainObject = inject('domainObject');
const objectPath = inject('objectPath');
const openmct = inject('openmct');
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
objectPath,
openmct
);
return {
EventBus,
alignmentData,
resetAlignment
};
},
data() {
return {
altPressed: false,
annotatedPointsBySeries: {},
highlights: [],
annotationSelectionsBySeries: {},
annotationsEverLoaded: false,
lockHighlightPoint: false,
yKeyOptions: [],
yAxisLabel: '',
rectangles: [],
plotHistory: [],
selectedXKeyOption: {},
xKeyOptions: [],
pending: 0,
isRealTime: this.openmct.time.isRealTime(),
loaded: false,
isTimeOutOfSync: false,
isFrozenOnMouseDown: false,
cursorGuide: this.initCursorGuide,
gridLines: this.initGridLines,
yAxes: [],
hiddenYAxisIds: [],
yAxisListWithRange: [],
config: {}
};
},
computed: {
xAxisStyle() {
let leftOffset = 0;
if (this.alignmentData.leftWidth) {
leftOffset = this.alignmentData.multiple ? 2 * AXES_PADDING : AXES_PADDING;
}
let style = {
left: `${this.alignmentData.leftWidth + leftOffset}px`
};
if (this.alignmentData.rightWidth) {
style.right = `${this.alignmentData.rightWidth + AXES_PADDING}px`;
}
return style;
},
yAxesIds() {
return this.yAxes.filter((yAxis) => yAxis.seriesCount > 0);
},
isNestedWithinAStackedPlot() {
const isNavigatedObject = this.openmct.router.isNavigatedObject(
[this.domainObject].concat(this.objectPath)
);
return (
!isNavigatedObject &&
this.objectPath.find(
(pathObject, pathObjIndex) => pathObject.type === 'telemetry.plot.stacked'
)
);
},
isFrozen() {
return this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
},
annotationViewingAndEditingAllowed() {
// only allow annotations viewing/editing if plot is paused or in fixed time mode
return this.isFrozen || !this.isRealTime;
},
seriesDataLoaded() {
return this.pending === 0 && this.loaded;
}
},
watch: {
initGridLines(newGridLines) {
this.gridLines = newGridLines;
},
initCursorGuide(newCursorGuide) {
this.cursorGuide = newCursorGuide;
}
},
created() {
this.abortController = new AbortController();
},
mounted() {
this.seriesModels = [];
this.config = {};
this.yAxisIdVisibility = {};
this.offsetWidth = 0;
document.addEventListener('keydown', this.handleKeyDown);
document.addEventListener('keyup', this.handleKeyUp);
eventHelpers.extend(this);
this.updateMode = this.updateMode.bind(this);
this.updateDisplayBounds = this.updateDisplayBounds.bind(this);
this.setTimeContext = this.setTimeContext.bind(this);
this.config = this.getConfig();
this.yAxes = [
{
id: this.config.yAxis.id,
seriesCount: 0
}
];
if (this.config.additionalYAxes) {
this.yAxes = this.yAxes.concat(
this.config.additionalYAxes.map((yAxis) => {
return {
id: yAxis.id,
seriesCount: 0
};
})
);
}
this.$emit('config-loaded', true);
this.listenTo(this.config.series, 'add', this.addSeries, this);
this.listenTo(this.config.series, 'remove', this.removeSeries, this);
this.config.series.models.forEach(this.addSeries, this);
this.filterObserver = this.openmct.objects.observe(
this.domainObject,
'configuration.filters',
this.updateFiltersAndResubscribe
);
this.removeStatusListener = this.openmct.status.observe(
this.domainObject.identifier,
this.updateStatus
);
this.openmct.objectViews.on('clearData', this.clearData);
this.EventBus.$on('loading-complete', this.loadAnnotationsIfAllowed);
this.openmct.selection.on('change', this.updateSelection);
this.yAxisListWithRange = [this.config.yAxis, ...this.config.additionalYAxes];
this.$nextTick(() => {
this.setTimeContext();
this.loaded = true;
});
},
beforeUnmount() {
this.resetAlignment();
this.abortController.abort();
this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
document.body.removeEventListener('click', this.cancelSelection);
this.EventBus.$off('loading-complete', this.loadAnnotationsIfAllowed);
this.destroy();
},
methods: {
async updateSelection(selection) {
const selectionContext = selection?.[0]?.[0]?.context?.item;
// on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true
// We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations.
const selectionType = selection?.[0]?.[0]?.context?.type;
const validSelectionTypes = ['clicked-on-plot-selection', 'annotation-search-result'];
const isAnnotationSearchResult = selectionType === 'annotation-search-result';
if (!validSelectionTypes.includes(selectionType)) {
// wrong type of selection
return;
}
if (
selectionContext &&
!isAnnotationSearchResult &&
this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)
) {
return;
}
await this.waitForAxesToLoad();
const selectedAnnotations = selection?.[0]?.[0]?.context?.annotations;
//This section is only for the annotations search results entry to displaying annotations
if (isAnnotationSearchResult) {
this.showAnnotationsFromSearchResults(selectedAnnotations);
}
//This section is common to all entry points for annotation display
this.prepareExistingAnnotationSelection(selectedAnnotations);
},
cancelSelection(event) {
if (this.$refs?.plot) {
const clickedInsidePlot = this.$refs.plot.contains(event.target);
// unfortunate side effect from possibly being detached from the DOM when
// adding/deleting tags, so closest() won't work
const clickedTagEditor = Object.values(TagEditorClassNames).some((className) => {
return event.target.classList.contains(className);
});
const clickedInsideInspector = event.target.closest('.js-inspector') !== null;
const clickedOption = event.target.closest('.js-autocomplete-options') !== null;
if (!clickedInsidePlot && !clickedInsideInspector && !clickedOption && !clickedTagEditor) {
this.rectangles = [];
this.annotationSelectionsBySeries = {};
this.selectPlot();
document.body.removeEventListener('click', this.cancelSelection);
}
}
},
waitForAxesToLoad() {
return new Promise((resolve) => {
// When there is no plot data, the ranges can be undefined
// in which case we should not perform selection.
const currentXaxis = this.config.xAxis.get('displayRange');
const currentYaxis = this.config.yAxis.get('displayRange');
if (!currentXaxis || !currentYaxis) {
this.EventBus.$once('loading-complete', resolve);
} else {
resolve();
}
});
},
showAnnotationsFromSearchResults(selectedAnnotations) {
if (selectedAnnotations?.length) {
// pause the plot if we haven't already so we can actually display
// the annotations
this.freeze();
// just use first annotation
const boundingBoxes = selectedAnnotations[0].targets;
let minX = Number.MAX_SAFE_INTEGER;
let minY = Number.MAX_SAFE_INTEGER;
let maxX = Number.MIN_SAFE_INTEGER;
let maxY = Number.MIN_SAFE_INTEGER;
boundingBoxes.forEach((boundingBox) => {
if (boundingBox.minX < minX) {
minX = boundingBox.minX;
}
if (boundingBox.maxX > maxX) {
maxX = boundingBox.maxX;
}
if (boundingBox.maxY > maxY) {
maxY = boundingBox.maxY;
}
if (boundingBox.minY < minY) {
minY = boundingBox.minY;
}
});
this.config.xAxis.set('displayRange', {
min: minX,
max: maxX
});
this.config.yAxis.set('displayRange', {
min: minY,
max: maxY
});
//Zoom out just a touch so that the highlighted section for annotations doesn't take over the whole view - which is not a nice look.
this.zoom('out', 0.2);
}
},
handleKeyDown(event) {
if (event.key === 'Alt') {
this.altPressed = true;
}
},
handleKeyUp(event) {
if (event.key === 'Alt') {
this.altPressed = false;
}
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.followTimeContext();
},
followTimeContext() {
this.updateMode();
this.updateDisplayBounds(this.timeContext.getBounds());
this.timeContext.on('modeChanged', this.updateMode);
this.timeContext.on('boundsChanged', this.updateDisplayBounds);
this.synchronized(true);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off('modeChanged', this.updateMode);
this.timeContext.off('boundsChanged', this.updateDisplayBounds);
}
},
getConfig() {
const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
let config = configStore.get(configId);
if (!config) {
config = new PlotConfigurationModel({
id: configId,
domainObject: this.domainObject,
openmct: this.openmct,
palette: this.colorPalette,
callback: (data) => {
this.data = data;
}
});
configStore.add(configId, config);
}
return config;
},
addSeries(series, index) {
const yAxisId = series.get('yAxisId');
this.updateAxisUsageCount(yAxisId, 1);
this.seriesModels[index] = series;
this.listenTo(series, 'change:xKey', this.setDisplayRange.bind(this, series), this);
this.listenTo(series, 'change:yKey', this.loadSeriesData.bind(this, series), this);
this.listenTo(series, 'change:interpolate', this.loadSeriesData.bind(this, series), this);
this.listenTo(series, 'change:yAxisId', this.updateTicksAndSeriesForYAxis, this);
this.loadSeriesData(series);
},
removeSeries(plotSeries, index) {
const yAxisId = plotSeries.get('yAxisId');
this.updateAxisUsageCount(yAxisId, -1);
this.seriesModels.splice(index, 1);
this.stopListening(plotSeries);
},
updateTicksAndSeriesForYAxis(newAxisId, oldAxisId) {
this.updateAxisUsageCount(oldAxisId, -1);
this.updateAxisUsageCount(newAxisId, 1);
},
updateAxisUsageCount(yAxisId, updateCountBy) {
const foundYAxis = this.yAxes.find((yAxis) => yAxis.id === yAxisId);
if (foundYAxis) {
foundYAxis.seriesCount = foundYAxis.seriesCount + updateCountBy;
}
},
loadAnnotationsIfAllowed() {
if (this.annotationViewingAndEditingAllowed) {
this.loadAnnotations();
}
},
async loadAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) {
// don't bother loading annotations if there are no tags
return;
}
const rawAnnotationsForPlot = [];
await Promise.all(
this.seriesModels.map(async (seriesModel) => {
const seriesAnnotations = await this.openmct.annotation.getAnnotations(
seriesModel.model.identifier,
this.abortController.signal
);
rawAnnotationsForPlot.push(...seriesAnnotations);
})
);
if (rawAnnotationsForPlot) {
this.annotatedPointsBySeries = this.findAnnotationPoints(rawAnnotationsForPlot);
}
this.annotationsEverLoaded = true;
},
loadSeriesData(series) {
//this check ensures that duplicate requests don't happen on load
if (!this.timeContext) {
return;
}
if (this.$parent.$refs.plotWrapper.offsetWidth === 0) {
this.scheduleLoad(series);
return;
}
this.offsetWidth = this.$parent.$refs.plotWrapper.offsetWidth;
this.startLoading();
const bounds = this.timeContext.getBounds();
const options = {
size: this.$parent.$refs.plotWrapper.offsetWidth,
domain: this.config.xAxis.get('key'),
start: bounds.start,
end: bounds.end
};
series.load(options).then(this.stopLoading.bind(this));
},
loadMoreData(range, purge) {
this.config.series.forEach((plotSeries) => {
this.offsetWidth = this.$parent.$refs.plotWrapper.offsetWidth;
this.startLoading();
plotSeries
.load({
size: this.offsetWidth,
start: range.min,
end: range.max,
domain: this.config.xAxis.get('key')
})
.then(this.stopLoading.bind(this));
if (purge) {
plotSeries.purgeRecordsOutsideRange(range);
}
});
},
scheduleLoad(series) {
if (!this.scheduledLoads) {
this.startLoading();
this.scheduledLoads = [];
this.checkForSize = setInterval(
function () {
if (this.$parent.$refs.plotWrapper.offsetWidth === 0) {
return;
}
this.stopLoading();
this.scheduledLoads.forEach(this.loadSeriesData, this);
delete this.scheduledLoads;
clearInterval(this.checkForSize);
delete this.checkForSize;
}.bind(this)
);
}
if (this.scheduledLoads.indexOf(series) === -1) {
this.scheduledLoads.push(series);
}
},
startLoading() {
this.pending += 1;
this.updateLoading();
},
stopLoading() {
this.pending -= 1;
this.updateLoading();
if (this.pending === 0) {
this.EventBus.$emit('loading-complete');
}
},
updateLoading() {
this.$emit('loading-updated', this.pending > 0);
},
updateFiltersAndResubscribe(updatedFilters) {
this.config.series.forEach(function (series) {
series.updateFiltersAndRefresh(updatedFilters[series.keyString]);
});
},
clearSeries() {
this.config.series.forEach(function (series) {
series.reset();
});
},
shareCommonParent(domainObjectToFind) {
return false;
},
compositionPathContainsId(domainObjectToFind) {
if (!domainObjectToFind.composition) {
return false;
}
return domainObjectToFind.composition.some((compositionIdentifier) => {
return this.openmct.objects.areIdsEqual(
compositionIdentifier,
this.domainObject.identifier
);
});
},
clearData(domainObjectToClear) {
// If we don't have an object to clear (global), or the IDs are equal, just clear the data.
// If we have an object to clear, but the IDs don't match, we need to check the composition
// of the object we've been asked to clear to see if it contains the id we're looking for.
// This happens with stacked plots for example.
// If we find the ID, clear the plot.
if (
!domainObjectToClear ||
this.openmct.objects.areIdsEqual(
domainObjectToClear.identifier,
this.domainObject.identifier
) ||
this.compositionPathContainsId(domainObjectToClear)
) {
this.clearSeries();
}
},
setDisplayRange(series, xKey) {
if (this.config.series.models.length !== 1) {
return;
}
const displayRange = series.getDisplayRange(xKey);
this.config.xAxis.set('range', displayRange);
},
updateMode() {
this.isRealTime = this.timeContext.isRealTime();
},
/**
* Track latest display bounds. Forces update when not receiving ticks.
*/
updateDisplayBounds(bounds, isTick) {
const newRange = {
min: bounds.start,
max: bounds.end
};
this.config.xAxis.set('range', newRange);
if (!isTick) {
this.annotatedPointsBySeries = {};
this.clearPanZoomHistory();
this.synchronizeIfBoundsMatch();
this.loadMoreData(newRange, true);
} else {
// If we're not paused, panning or zooming (time conductor and plot x-axis times are not out of sync)
// Drop any data that is more than 1x (max-min) before min.
// Limit these purges to once a second.
const isPanningOrZooming = this.isTimeOutOfSync;
const purgeRecords =
!this.isFrozen && !isPanningOrZooming && (!this.nextPurge || this.nextPurge < Date.now());
if (purgeRecords) {
const keepRange = {
min: newRange.min - (newRange.max - newRange.min),
max: newRange.max
};
this.config.series.forEach(function (series) {
series.purgeRecordsOutsideRange(keepRange);
});
this.nextPurge = Date.now() + 1000;
}
}
},
/**
* Handle end of user viewport change: load more data for current display
* bounds, and mark view as synchronized if necessary.
*/
userViewportChangeEnd() {
this.synchronizeIfBoundsMatch();
const xDisplayRange = this.config.xAxis.get('displayRange');
this.loadMoreData(xDisplayRange);
},
/**
* mark view as synchronized if bounds match configured bounds.
*/
synchronizeIfBoundsMatch() {
const xDisplayRange = this.config.xAxis.get('displayRange');
const xRange = this.config.xAxis.get('range');
this.synchronized(xRange.min === xDisplayRange.min && xRange.max === xDisplayRange.max);
},
/**
* Getter/setter for "synchronized" value. If not synchronized and
* time conductor is in clock mode, will mark objects as unsynced so that
* displays can update accordingly.
*/
synchronized(value) {
const isRealTime = this.timeContext.isRealTime();
if (typeof value !== 'undefined') {
this._synchronized = value;
this.isTimeOutOfSync = value !== true;
const isUnsynced = isRealTime && !value;
this.setStatus(isUnsynced);
}
return this._synchronized;
},
setStatus(isNotInSync) {
const outOfSync =
isNotInSync === true || this.isTimeOutOfSync === true || this.isFrozen === true;
if (outOfSync === true) {
this.openmct.status.set(this.domainObject.identifier, 'timeconductor-unsynced');
} else {
this.openmct.status.set(this.domainObject.identifier, '');
}
},
initCanvas() {
if (this.canvas) {
this.stopListening(this.canvas);
}
this.canvas = this.$refs.chartContainer.querySelectorAll('canvas')[1];
this.listenTo(this.canvas, 'mousemove', this.trackMousePosition, this);
this.listenTo(this.canvas, 'mouseleave', this.untrackMousePosition, this);
this.listenTo(this.canvas, 'mousedown', this.onMouseDown, this);
this.listenTo(this.canvas, 'click', this.selectNearbyAnnotations, this);
this.listenTo(this.canvas, 'wheel', this.wheelZoom, this);
},
marqueeAnnotations(annotationsToSelect) {
annotationsToSelect.forEach((annotationToSelect) => {
annotationToSelect.targets.forEach((target) => {
const targetKeyString = target.keyString;
const series = this.seriesModels.find(
(seriesModel) => seriesModel.keyString === targetKeyString
);
if (!series) {
return;
}
const yAxisId = series.get('yAxisId');
const rectangle = {
start: {
x: target.minX,
y: [target.minY],
yAxisIds: [yAxisId]
},
end: {
x: target.maxX,
y: [target.maxY],
yAxisIds: [yAxisId]
},
color: [1, 1, 1, 0.1]
};
this.rectangles.push(rectangle);
});
});
},
gatherNearbyAnnotations() {
const nearbyAnnotations = [];
this.config.series.models.forEach((series) => {
if (series?.closest?.annotationsById) {
Object.values(series.closest.annotationsById).forEach((closeAnnotation) => {
const addedAnnotationAlready = nearbyAnnotations.some((annotation) => {
return (
_.isEqual(annotation.targets, closeAnnotation.targets) &&
_.isEqual(annotation.tags, closeAnnotation.tags)
);
});
if (!addedAnnotationAlready) {
nearbyAnnotations.push(closeAnnotation);
}
});
}
});
return nearbyAnnotations;
},
prepareExistingAnnotationSelection(annotations) {
const targetDomainObjects = this.config.series.models.map((series) => {
return series.domainObject;
});
const targetDetails = [];
const uniqueBoundsAnnotations = [];
annotations.forEach((annotation) => {
// for each target, push toRaw
annotation.targets.forEach((target) => {
targetDetails.push(toRaw(target));
});
const boundingBoxAlreadyAdded = uniqueBoundsAnnotations.some((existingAnnotation) => {
const existingBoundingBox = Object.values(existingAnnotation.targets)[0];
const newBoundingBox = Object.values(annotation.targets)[0];
return (
existingBoundingBox.minX === newBoundingBox.minX &&
existingBoundingBox.minY === newBoundingBox.minY &&
existingBoundingBox.maxX === newBoundingBox.maxX &&
existingBoundingBox.maxY === newBoundingBox.maxY
);
});
if (!boundingBoxAlreadyAdded) {
uniqueBoundsAnnotations.push(annotation);
}
});
this.marqueeAnnotations(uniqueBoundsAnnotations);
return {
targetDomainObjects,
targetDetails
};
},
initialize() {
this.handleWindowResize = _.debounce(this.handleWindowResize, 500);
this.plotContainerResizeObserver = new ResizeObserver(this.handleWindowResize);
this.plotContainerResizeObserver.observe(this.$parent.$refs.plotWrapper);
// Setup canvas etc.
this.xScale = new LinearScale(this.config.xAxis.get('displayRange'));
this.yScale = [];
this.yAxisListWithRange.forEach((yAxis) => {
this.yScale.push({
id: yAxis.id,
scale: new LinearScale(yAxis.get('displayRange'))
});
});
this.pan = undefined;
this.marquee = undefined;
this.chartElementBounds = undefined;
this.tickUpdate = false;
this.initCanvas();
this.config.yAxisLabel = this.config.yAxis.get('label');
this.listenTo(this.config.xAxis, 'change:displayRange', this.onXAxisChange, this);
this.yAxisListWithRange.forEach((yAxis) => {
this.listenTo(yAxis, 'change:displayRange', this.onYAxisChange.bind(this, yAxis.id), this);
});
},
onXAxisChange(displayBounds) {
if (displayBounds) {
this.xScale.domain(displayBounds);
}
},
onYAxisChange(yAxisId, displayBounds) {
if (displayBounds) {
this.yScale
.filter((yAxis) => yAxis.id === yAxisId)
.forEach((yAxis) => {
yAxis.scale.domain(displayBounds);
});
}
},
toggleSeriesForYAxis({ id, visible }) {
//if toggling to visible, re-fetch the data for the series that are part of this y Axis
if (visible === true) {
this.config.series.models
.filter((model) => model.get('yAxisId') === id)
.forEach(this.loadSeriesData, this);
}
this.yAxisIdVisibility[id] = visible;
this.hiddenYAxisIds = Object.keys(this.yAxisIdVisibility)
.map(Number)
.filter((key) => {
return this.yAxisIdVisibility[key] === false;
});
},
trackMousePosition(event) {
this.trackChartElementBounds(event);
this.xScale.range({
min: 0,
max: this.chartElementBounds.width
});
this.yScale.forEach((yAxis) => {
yAxis.scale.range({
min: 0,
max: this.chartElementBounds.height
});
});
this.positionOverElement = {
x: event.clientX - this.chartElementBounds.left,
y: this.chartElementBounds.height - (event.clientY - this.chartElementBounds.top)
};
const yLocationForPositionOverPlot = this.yScale.map((yAxis) =>
yAxis.scale.invert(this.positionOverElement.y)
);
const yAxisIds = this.yScale.map((yAxis) => yAxis.id);
// Also store the order of yAxisIds so that we can associate the y location to the yAxis
this.positionOverPlot = {
x: this.xScale.invert(this.positionOverElement.x),
y: yLocationForPositionOverPlot,
yAxisIds
};
if (this.cursorGuide) {
this.updateCrosshairs(event);
}
this.highlightValues(this.positionOverPlot.x);
this.updateMarquee();
this.updatePan();
event.preventDefault();
},
getYPositionForYAxis(object, yAxis) {
const index = object.yAxisIds.findIndex((yAxisId) => yAxisId === yAxis.get('id'));
return object.y[index];
},
updateCrosshairs(event) {
this.$refs.cursorGuideVertical.style.left = event.clientX - this.chartElementBounds.x + 'px';
this.$refs.cursorGuideHorizontal.style.top = event.clientY - this.chartElementBounds.y + 'px';
},
trackChartElementBounds(event) {
if (event.target === this.canvas) {
this.chartElementBounds = event.target.getBoundingClientRect();
}
},
onPlotHighlightSet($e, point) {
if (point === this.highlightPoint) {
return;
}
this.highlightValues(point);
},
highlightValues(point) {
this.highlightPoint = point;
if (this.lockHighlightPoint) {
return;
}
if (!point) {
this.highlights = [];
this.config.series.models.forEach((series) => delete series.closest);
} else {
this.highlights = this.config.series.models
.filter((series) => series.getSeriesData().length > 0)
.map((series) => {
series.closest = series.nearestPoint(point);
return {
seriesKeyString: series.keyString,
point: series.closest
};
});
}
this.$emit('highlights', this.highlights);
},
untrackMousePosition() {
this.positionOverElement = undefined;
this.positionOverPlot = undefined;
this.highlightValues();
},
onMouseDown(event) {
// do not monitor drag events on browser context click
if (event.ctrlKey) {
return;
}
this.listenTo(window, 'mouseup', this.onMouseUp, this);
this.listenTo(window, 'mousemove', this.trackMousePosition, this);
if (!this.options.compact) {
// track frozen state on mouseDown to be read on mouseUp
const isFrozen =
this.config.xAxis.get('frozen') === true && this.config.yAxis.get('frozen') === true;
this.isFrozenOnMouseDown = isFrozen;
if (event.altKey && !event.shiftKey) {
return this.startPan(event);
} else if (event.altKey && event.shiftKey) {
this.freeze();
return this.startMarquee(event, true);
} else {
return this.startMarquee(event, false);
}
}
},
onMouseUp(event) {
this.stopListening(window, 'mouseup', this.onMouseUp, this);
this.stopListening(window, 'mousemove', this.trackMousePosition, this);
if (this.isMouseClick() && event.shiftKey) {
this.lockHighlightPoint = !this.lockHighlightPoint;
this.$emit('lock-highlight-point', this.lockHighlightPoint);
}
if (this.pan) {
return this.endPan(event);
}
if (this.marquee) {
this.endMarquee(event);
}
// resume the plot if no pan, zoom, or drag action is taken
// needs to follow endMarquee so that plotHistory is pruned
const isAction = Boolean(this.plotHistory.length);
if (!isAction && !this.isFrozenOnMouseDown) {
this.clearPanZoomHistory();
this.synchronizeIfBoundsMatch();
}
},
isMouseClick() {
// We may not have a marquee if we've disabled pan/zoom, but we still need to know if it's a mouse click for highlights and lock points.
if (!this.marquee && !this.positionOverPlot) {
return false;
}
const { start, end } = this.marquee ?? {
start: this.positionOverPlot,
end: this.positionOverPlot
};
const someYPositionOverPlot = start.y.some((y) => y);
return start.x === end.x && someYPositionOverPlot;
},
updateMarquee() {
if (!this.marquee) {
return;
}
this.marquee.end = this.positionOverPlot;
this.marquee.endPixels = this.positionOverElement;
},
startMarquee(event, annotationEvent) {
this.rectangles = [];
this.annotationSelectionsBySeries = {};
this.canvas.classList.remove('plot-drag');
this.canvas.classList.add('plot-marquee');
this.trackMousePosition(event);
if (this.positionOverPlot) {
this.freeze();
this.marquee = {
startPixels: this.positionOverElement,
endPixels: this.positionOverElement,
start: this.positionOverPlot,
end: this.positionOverPlot,
color: [1, 1, 1, 0.25]
};
if (annotationEvent) {
this.marquee.annotationEvent = true;
}
this.rectangles.push(this.marquee);
this.trackHistory();
}
},
selectNearbyAnnotations(event) {
// need to stop propagation right away to prevent selecting the plot itself
event.stopPropagation();
const nearbyAnnotations = this.gatherNearbyAnnotations();
if (
this.annotationViewingAndEditingAllowed &&
Object.keys(this.annotationSelectionsBySeries).length
) {
//no annotations were found, but we are adding some now
return;
}
if (this.annotationViewingAndEditingAllowed && nearbyAnnotations.length) {
//show annotations if some were found
const { targetDomainObjects, targetDetails } =
this.prepareExistingAnnotationSelection(nearbyAnnotations);
this.selectPlotAnnotations({
targetDetails,
targetDomainObjects,
annotations: nearbyAnnotations
});
return;
}
//Fall through to here if either there is no new selection add tags or no existing annotations were retrieved
this.selectPlot();
},
selectPlot() {
// should show plot itself if we didn't find any annotations
const selection = this.createPathSelection();
this.openmct.selection.select(selection, true);
},
createPathSelection() {
let selection = [];
selection.unshift({
element: this.$el,
context: {
item: this.domainObject
}
});
this.objectPath.forEach((pathObject, index) => {
selection.push({
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: pathObject
}
});
});
return selection;
},
selectPlotAnnotations({ targetDetails, targetDomainObjects, annotations }) {
const annotationContext = {
type: 'clicked-on-plot-selection',
targetDetails,
targetDomainObjects,
annotations,
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
onAnnotationChange: this.onAnnotationChange
};
const selection = this.createPathSelection();
if (
selection.length &&
this.openmct.objects.areIdsEqual(
selection[0].context.item.identifier,
this.domainObject.identifier
)
) {
selection[0].context = {
...selection[0].context,
...annotationContext
};
} else {
selection.unshift({
element: this.$el,
context: {
item: this.domainObject,
...annotationContext
}
});
}
this.openmct.selection.select(selection, true);
document.body.addEventListener('click', this.cancelSelection);
},
selectNewPlotAnnotations(boundingBoxPerYAxis, pointsInBoxBySeries, event) {
let targetDomainObjects = [];
let targetDetails = [];
let annotations = [];
Object.keys(pointsInBoxBySeries).forEach((seriesKey) => {
const seriesModel = this.getSeries(seriesKey);
const boundingBoxWithId = boundingBoxPerYAxis.find(
(box) => box.id === seriesModel.get('yAxisId')
);
targetDetails.push({ ...boundingBoxWithId?.boundingBox, keyString: seriesKey });
targetDomainObjects.push(seriesModel.domainObject);
});
this.selectPlotAnnotations({
targetDetails,
targetDomainObjects,
annotations
});
},
findAnnotationPoints(rawAnnotations) {
const annotationsBySeries = {};
rawAnnotations.forEach((rawAnnotation) => {
if (rawAnnotation.targets) {
const targetValues = rawAnnotation.targets;
const targetKeys = rawAnnotation.targets.map((target) => target.keyString);
if (targetValues && targetValues.length) {
let boundingBoxPerYAxis = [];
targetValues.forEach((boundingBox, index) => {
const seriesId = targetKeys[index];
const series = this.seriesModels.find(
(seriesModel) => seriesModel.keyString === seriesId
);
if (!series) {
return;
}
if (!annotationsBySeries[seriesId]) {
annotationsBySeries[seriesId] = [];
}
boundingBoxPerYAxis.push({
id: series.get('yAxisId'),
boundingBox
});
});
const pointsInBoxBySeries = this.getPointsInBoxBySeries(
boundingBoxPerYAxis,
rawAnnotation
);
if (pointsInBoxBySeries && Object.values(pointsInBoxBySeries).length) {
Object.keys(pointsInBoxBySeries).forEach((seriesKeyString) => {
const pointsInBox = pointsInBoxBySeries[seriesKeyString];
if (pointsInBox && pointsInBox.length) {
if (!annotationsBySeries[seriesKeyString]) {
annotationsBySeries[seriesKeyString] = [];
}
annotationsBySeries[seriesKeyString].push(...pointsInBox);
}
});
}
}
}
});
return annotationsBySeries;
},
searchWithFlatbush(seriesData, seriesModel, boundingBox) {
const flatbush = new Flatbush(seriesData.length);
seriesData.forEach((point) => {
const x = seriesModel.getXVal(point);
const y = seriesModel.getYVal(point);
flatbush.add(x, y, x, y);
});
flatbush.finish();
const rangeResults = flatbush.search(
boundingBox.minX,
boundingBox.minY,
boundingBox.maxX,
boundingBox.maxY
);
return rangeResults;
},
getSeries(keyStringToFind) {
const foundSeries = this.seriesModels.find((series) => {
return series.keyString === keyStringToFind;
});
return foundSeries;
},
getPointsInBoxBySeries(boundingBoxPerYAxis, rawAnnotation) {
// load series models in KD-Trees
const searchResultsBySeries = {};
this.seriesModels.forEach((seriesModel) => {
const boundingBoxWithId = boundingBoxPerYAxis.find(
(box) => box.id === seriesModel.get('yAxisId')
);
const boundingBox = boundingBoxWithId?.boundingBox;
//Series was probably added after the last annotations were saved
if (!boundingBox) {
return;
}
const seriesData = seriesModel.getSeriesData();
if (seriesData && seriesData.length) {
searchResultsBySeries[seriesModel.keyString] = [];
const rangeResults = this.searchWithFlatbush(seriesData, seriesModel, boundingBox);
rangeResults.forEach((id) => {
const seriesDatum = seriesData[id];
if (seriesDatum) {
const result = {
point: seriesDatum
};
searchResultsBySeries[seriesModel.keyString].push(result);
}
if (rawAnnotation) {
if (!seriesDatum.annotationsById) {
seriesDatum.annotationsById = {};
}
const annotationKeyString = this.openmct.objects.makeKeyString(
rawAnnotation.identifier
);
seriesDatum.annotationsById[annotationKeyString] = rawAnnotation;
}
});
}
});
return searchResultsBySeries;
},
endAnnotationMarquee(event) {
const boundingBoxPerYAxis = [];
this.yAxisListWithRange.forEach((yAxis, yIndex) => {
const minX = Math.min(this.marquee.start.x, this.marquee.end.x);
const minY = Math.min(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]);
const maxX = Math.max(this.marquee.start.x, this.marquee.end.x);
const maxY = Math.max(this.marquee.start.y[yIndex], this.marquee.end.y[yIndex]);
const boundingBox = {
minX,
minY,
maxX,
maxY
};
boundingBoxPerYAxis.push({
id: yAxis.get('id'),
boundingBox
});
});
const pointsInBoxBySeries = this.getPointsInBoxBySeries(boundingBoxPerYAxis);
if (!pointsInBoxBySeries || Object.values(pointsInBoxBySeries).length === 0) {
return;
}
this.annotationSelectionsBySeries = pointsInBoxBySeries;
this.selectNewPlotAnnotations(boundingBoxPerYAxis, this.annotationSelectionsBySeries, event);
},
endZoomMarquee() {
const startPixels = this.marquee.startPixels;
const endPixels = this.marquee.endPixels;
const marqueeDistance = Math.sqrt(
Math.pow(startPixels.x - endPixels.x, 2) + Math.pow(startPixels.y - endPixels.y, 2)
);
// Don't zoom if mouse moved less than 7.5 pixels.
if (marqueeDistance > 7.5) {
this.config.xAxis.set('displayRange', {
min: Math.min(this.marquee.start.x, this.marquee.end.x),
max: Math.max(this.marquee.start.x, this.marquee.end.x)
});
this.yAxisListWithRange.forEach((yAxis) => {
const yStartPosition = this.getYPositionForYAxis(this.marquee.start, yAxis);
const yEndPosition = this.getYPositionForYAxis(this.marquee.end, yAxis);
yAxis.set('displayRange', {
min: Math.min(yStartPosition, yEndPosition),
max: Math.max(yStartPosition, yEndPosition)
});
});
this.userViewportChangeEnd();
} else {
// A history entry is created by startMarquee, need to remove
// if marquee zoom doesn't occur.
this.plotHistory.pop();
}
},
endMarquee(event) {
if (this.marquee.annotationEvent) {
this.endAnnotationMarquee(event);
} else {
this.endZoomMarquee();
this.rectangles = [];
}
this.marquee = null;
},
onAnnotationChange(annotations) {
if (this.marquee) {
this.marquee.annotationEvent = false;
this.endMarquee();
}
this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
},
zoom(zoomDirection, zoomFactor) {
const currentXaxis = this.config.xAxis.get('displayRange');
let doesYAxisHaveRange = false;
this.yAxisListWithRange.forEach((yAxisModel) => {
if (yAxisModel.get('displayRange')) {
doesYAxisHaveRange = true;
}
});
// when there is no plot data, the ranges can be undefined
// in which case we should not perform zoom
if (!currentXaxis || !doesYAxisHaveRange) {
return;
}
this.freeze();
this.trackHistory();
const xAxisDist = (currentXaxis.max - currentXaxis.min) * zoomFactor;
if (zoomDirection === 'in') {
this.config.xAxis.set('displayRange', {
min: currentXaxis.min + xAxisDist,
max: currentXaxis.max - xAxisDist
});
this.yAxisListWithRange.forEach((yAxisModel) => {
const currentYaxis = yAxisModel.get('displayRange');
if (!currentYaxis) {
return;
}
const yAxisDist = (currentYaxis.max - currentYaxis.min) * zoomFactor;
yAxisModel.set('displayRange', {
min: currentYaxis.min + yAxisDist,
max: currentYaxis.max - yAxisDist
});
});
} else if (zoomDirection === 'out') {
this.config.xAxis.set('displayRange', {
min: currentXaxis.min - xAxisDist,
max: currentXaxis.max + xAxisDist
});
this.yAxisListWithRange.forEach((yAxisModel) => {
const currentYaxis = yAxisModel.get('displayRange');
if (!currentYaxis) {
return;
}
const yAxisDist = (currentYaxis.max - currentYaxis.min) * zoomFactor;
yAxisModel.set('displayRange', {
min: currentYaxis.min - yAxisDist,
max: currentYaxis.max + yAxisDist
});
});
}
this.userViewportChangeEnd();
},
wheelZoom(event) {
const ZOOM_AMT = 0.1;
event.preventDefault();
if (event.wheelDelta === undefined || !this.positionOverPlot) {
return;
}
let xDisplayRange = this.config.xAxis.get('displayRange');
let doesYAxisHaveRange = false;
this.yAxisListWithRange.forEach((yAxisModel) => {
if (yAxisModel.get('displayRange')) {
doesYAxisHaveRange = true;
}
});
// when there is no plot data, the ranges can be undefined
// in which case we should not perform zoom
if (!xDisplayRange || !doesYAxisHaveRange) {
return;
}
this.freeze();
window.clearTimeout(this.stillZooming);
let xAxisDist = xDisplayRange.max - xDisplayRange.min;
let xDistMouseToMax = xDisplayRange.max - this.positionOverPlot.x;
let xDistMouseToMin = this.positionOverPlot.x - xDisplayRange.min;
let xAxisMaxDist = xDistMouseToMax / xAxisDist;
let xAxisMinDist = xDistMouseToMin / xAxisDist;
let plotHistoryStep;
if (!plotHistoryStep) {
const yRangeList = [];
this.yAxisListWithRange.map((yAxis) => yRangeList.push(yAxis.get('displayRange')));
plotHistoryStep = {
x: this.config.xAxis.get('displayRange'),
y: yRangeList
};
}
if (event.wheelDelta < 0) {
this.config.xAxis.set('displayRange', {
min: xDisplayRange.min + xAxisDist * ZOOM_AMT * xAxisMinDist,
max: xDisplayRange.max - xAxisDist * ZOOM_AMT * xAxisMaxDist
});
this.yAxisListWithRange.forEach((yAxisModel) => {
const yDisplayRange = yAxisModel.get('displayRange');
if (!yDisplayRange) {
return;
}
const yPosition = this.getYPositionForYAxis(this.positionOverPlot, yAxisModel);
let yAxisDist = yDisplayRange.max - yDisplayRange.min;
let yDistMouseToMax = yDisplayRange.max - yPosition;
let yDistMouseToMin = yPosition - yDisplayRange.min;
let yAxisMaxDist = yDistMouseToMax / yAxisDist;
let yAxisMinDist = yDistMouseToMin / yAxisDist;
yAxisModel.set('displayRange', {
min: yDisplayRange.min + yAxisDist * ZOOM_AMT * yAxisMinDist,
max: yDisplayRange.max - yAxisDist * ZOOM_AMT * yAxisMaxDist
});
});
} else if (event.wheelDelta >= 0) {
this.config.xAxis.set('displayRange', {
min: xDisplayRange.min - xAxisDist * ZOOM_AMT * xAxisMinDist,
max: xDisplayRange.max + xAxisDist * ZOOM_AMT * xAxisMaxDist
});
this.yAxisListWithRange.forEach((yAxisModel) => {
const yDisplayRange = yAxisModel.get('displayRange');
if (!yDisplayRange) {
return;
}
const yPosition = this.getYPositionForYAxis(this.positionOverPlot, yAxisModel);
let yAxisDist = yDisplayRange.max - yDisplayRange.min;
let yDistMouseToMax = yDisplayRange.max - yPosition;
let yDistMouseToMin = yPosition - yDisplayRange.min;
let yAxisMaxDist = yDistMouseToMax / yAxisDist;
let yAxisMinDist = yDistMouseToMin / yAxisDist;
yAxisModel.set('displayRange', {
min: yDisplayRange.min - yAxisDist * ZOOM_AMT * yAxisMinDist,
max: yDisplayRange.max + yAxisDist * ZOOM_AMT * yAxisMaxDist
});
});
}
this.stillZooming = window.setTimeout(
function () {
this.plotHistory.push(plotHistoryStep);
plotHistoryStep = undefined;
this.userViewportChangeEnd();
}.bind(this),
250
);
},
startPan(event) {
this.canvas.classList.add('plot-drag');
this.canvas.classList.remove('plot-marquee');
this.trackMousePosition(event);
this.freeze();
this.pan = {
start: this.positionOverPlot
};
event.preventDefault();
this.trackHistory();
return false;
},
updatePan() {
// calculate offset between points. Apply that offset to viewport.
if (!this.pan) {
return;
}
const dX = this.pan.start.x - this.positionOverPlot.x;
const xRange = this.config.xAxis.get('displayRange');
this.config.xAxis.set('displayRange', {
min: xRange.min + dX,
max: xRange.max + dX
});
const dY = [];
this.positionOverPlot.y.forEach((yAxisPosition, index) => {
const yAxisId = this.positionOverPlot.yAxisIds[index];
dY.push({
yAxisId: yAxisId,
y: this.pan.start.y[index] - yAxisPosition
});
});
this.yAxisListWithRange.forEach((yAxis) => {
const yRange = yAxis.get('displayRange');
if (!yRange) {
return;
}
const yIndex = dY.findIndex((y) => y.yAxisId === yAxis.get('id'));
yAxis.set('displayRange', {
min: yRange.min + dY[yIndex].y,
max: yRange.max + dY[yIndex].y
});
});
},
trackHistory() {
const yRangeList = [];
const yAxisIds = [];
this.yAxisListWithRange.forEach((yAxis) => {
yRangeList.push(yAxis.get('displayRange'));
yAxisIds.push(yAxis.get('id'));
});
this.plotHistory.push({
x: this.config.xAxis.get('displayRange'),
y: yRangeList,
yAxisIds
});
},
endPan() {
this.pan = undefined;
this.userViewportChangeEnd();
},
freeze() {
this.yAxisListWithRange.forEach((yAxis) => {
yAxis.set('frozen', true);
});
this.config.xAxis.set('frozen', true);
this.setStatus();
if (!this.annotationsEverLoaded) {
this.loadAnnotations();
}
},
resumeRealtimeData() {
// remove annotation selections
this.rectangles = [];
this.clearPanZoomHistory();
this.userViewportChangeEnd();
},
clearPanZoomHistory() {
this.yAxisListWithRange.forEach((yAxis) => {
yAxis.set('frozen', false);
});
this.config.xAxis.set('frozen', false);
this.setStatus();
this.plotHistory = [];
},
back() {
const previousAxisRanges = this.plotHistory.pop();
if (this.plotHistory.length === 0) {
this.resumeRealtimeData();
return;
}
this.config.xAxis.set('displayRange', previousAxisRanges.x);
this.yAxisListWithRange.forEach((yAxis) => {
const yPosition = this.getYPositionForYAxis(previousAxisRanges, yAxis);
yAxis.set('displayRange', yPosition);
});
this.userViewportChangeEnd();
},
setYAxisKey(yKey, yAxisId) {
const seriesForYAxis = this.config.series.models.filter(
(model) => model.get('yAxisId') === yAxisId
);
seriesForYAxis.forEach((model) => model.set('yKey', yKey));
},
pause() {
this.freeze();
},
showSynchronizeDialog() {
const isFixedTimespanMode = this.timeContext.isFixed();
if (!isFixedTimespanMode) {
const message = `
This action will change the Time Conductor to Fixed Timespan mode with this plot view's current time bounds.
Do you want to continue?
`;
let dialog = this.openmct.overlays.dialog({
title: 'Synchronize Time Conductor',
iconClass: 'alert',
size: 'fit',
message: message,
buttons: [
{
label: 'Ok',
callback: () => {
dialog.dismiss();
this.synchronizeTimeConductor();
}
},
{
label: 'Cancel',
callback: () => {
dialog.dismiss();
}
}
]
});
} else {
this.openmct.notifications.alert('Time conductor bounds have changed.');
this.synchronizeTimeConductor();
}
},
synchronizeTimeConductor() {
const range = this.config.xAxis.get('displayRange');
this.timeContext.setMode(MODES.fixed, {
start: range.min,
end: range.max
});
this.isTimeOutOfSync = false;
},
destroy() {
if (this.config) {
configStore.deleteStore(this.config.id);
}
this.config = {};
this.canvas = undefined;
this.abortController = undefined;
this.stopListening();
if (this.checkForSize) {
clearInterval(this.checkForSize);
delete this.checkForSize;
}
if (this.filterObserver) {
this.filterObserver();
}
if (this.removeStatusListener) {
this.removeStatusListener();
}
if (this.plotContainerResizeObserver) {
this.plotContainerResizeObserver.disconnect();
}
this.stopFollowingTimeContext();
this.openmct.objectViews.off('clearData', this.clearData);
},
updateStatus(status) {
this.$emit('status-updated', status);
},
handleWindowResize() {
const { plotWrapper } = this.$parent.$refs;
if (!plotWrapper) {
return;
}
const newOffsetWidth = plotWrapper.offsetWidth;
//we ignore when width gets smaller
const offsetChange = newOffsetWidth - this.offsetWidth;
if (offsetChange > OFFSET_THRESHOLD) {
this.offsetWidth = newOffsetWidth;
this.config.series.models.forEach(this.loadSeriesData, this);
}
},
toggleCursorGuide() {
this.cursorGuide = !this.cursorGuide;
this.$emit('cursor-guide', this.cursorGuide);
},
toggleGridLines() {
this.gridLines = !this.gridLines;
this.$emit('grid-lines', this.gridLines);
}
}
};
</script>