mirror of
https://github.com/nasa/openmct.git
synced 2025-05-10 20:43:00 +00:00
* Use setMode API to set the time span as well as the bounds instead of the old bounds time API. * Add test for synchronized time conductor via plots * Fix linting issue
1966 lines
61 KiB
Vue
1966 lines
61 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">
|
|
<y-axis
|
|
v-for="(yAxis, index) in yAxesIds"
|
|
:id="yAxis.id"
|
|
:key="`yAxis-${yAxis.id}-${index}`"
|
|
:has-multiple-left-axes="hasMultipleLeftAxes"
|
|
:position="yAxis.id > 2 ? 'right' : 'left'"
|
|
:class="{ 'plot-yaxis-right': yAxis.id > 2 }"
|
|
:tick-width="yAxis.tickWidth"
|
|
:used-tick-width="plotFirstLeftTickWidth"
|
|
:plot-left-tick-width="yAxis.id > 2 ? yAxis.tickWidth : plotLeftTickWidth"
|
|
@y-key-changed="setYAxisKey"
|
|
@plot-y-tick-width="onYTickWidthChange"
|
|
@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"
|
|
@plot-tick-width="onYTickWidthChange"
|
|
/>
|
|
|
|
<div
|
|
ref="chartContainer"
|
|
class="gl-plot-chart-wrapper"
|
|
:class="[{ 'alt-pressed': altPressed }]"
|
|
>
|
|
<mct-chart
|
|
: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"
|
|
@click="zoom('out', 0.2)"
|
|
></button>
|
|
<button class="c-button icon-plus" title="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/zoom"
|
|
@click="back()"
|
|
></button>
|
|
<button
|
|
class="c-button icon-reset"
|
|
title="Reset pan/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"
|
|
@click="pause()"
|
|
></button>
|
|
<button
|
|
v-if="isFrozen"
|
|
class="c-button icon-arrow-right pause-play is-paused"
|
|
title="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"
|
|
@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"
|
|
@click="toggleCursorGuide"
|
|
></button>
|
|
<button
|
|
class="c-button"
|
|
:class="{ 'icon-grid-on': gridLines, 'icon-grid-off': !gridLines }"
|
|
title="Toggle grid lines"
|
|
@click="toggleGridLines"
|
|
></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!--Cursor guides-->
|
|
<div
|
|
v-show="cursorGuide"
|
|
ref="cursorGuideVertical"
|
|
class="c-cursor-guide--v js-cursor-guide--v"
|
|
></div>
|
|
<div
|
|
v-show="cursorGuide"
|
|
ref="cursorGuideHorizontal"
|
|
class="c-cursor-guide--h js-cursor-guide--h"
|
|
></div>
|
|
</div>
|
|
<x-axis
|
|
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 { toRaw } from 'vue';
|
|
|
|
import { MODES } from '../../api/time/constants';
|
|
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', 'path', 'renderWhenVisible'],
|
|
props: {
|
|
options: {
|
|
type: Object,
|
|
default() {
|
|
return {
|
|
compact: false
|
|
};
|
|
}
|
|
},
|
|
initGridLines: {
|
|
type: Boolean,
|
|
default() {
|
|
return true;
|
|
}
|
|
},
|
|
initCursorGuide: {
|
|
type: Boolean,
|
|
default() {
|
|
return false;
|
|
}
|
|
},
|
|
parentYTickWidth: {
|
|
type: Object,
|
|
default() {
|
|
return {
|
|
leftTickWidth: 0,
|
|
rightTickWidth: 0,
|
|
hasMultipleLeftAxes: false
|
|
};
|
|
}
|
|
},
|
|
limitLineLabels: {
|
|
type: Object,
|
|
default() {
|
|
return undefined;
|
|
}
|
|
},
|
|
colorPalette: {
|
|
type: Object,
|
|
default() {
|
|
return undefined;
|
|
}
|
|
}
|
|
},
|
|
emits: [
|
|
'config-loaded',
|
|
'cursor-guide',
|
|
'grid-lines',
|
|
'loading-complete',
|
|
'loading-updated',
|
|
'plot-y-tick-width',
|
|
'highlights',
|
|
'lock-highlight-point',
|
|
'status-updated'
|
|
],
|
|
setup() {
|
|
const { EventBus } = useEventBus();
|
|
return {
|
|
EventBus
|
|
};
|
|
},
|
|
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() {
|
|
const rightAxis = this.yAxesIds.find((yAxis) => yAxis.id > 2);
|
|
const leftOffset = this.hasMultipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING;
|
|
let style = {
|
|
left: `${this.plotLeftTickWidth + leftOffset}px`
|
|
};
|
|
const parentRightAxisWidth = this.parentYTickWidth.rightTickWidth;
|
|
|
|
if (parentRightAxisWidth || rightAxis) {
|
|
style.right = `${(parentRightAxisWidth || rightAxis.tickWidth) + AXES_PADDING}px`;
|
|
}
|
|
|
|
return style;
|
|
},
|
|
yAxesIds() {
|
|
return this.yAxes.filter((yAxis) => yAxis.seriesCount > 0);
|
|
},
|
|
hasMultipleLeftAxes() {
|
|
return (
|
|
this.parentYTickWidth.hasMultipleLeftAxes ||
|
|
this.yAxes.filter((yAxis) => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1
|
|
);
|
|
},
|
|
isNestedWithinAStackedPlot() {
|
|
const isNavigatedObject = this.openmct.router.isNavigatedObject(
|
|
[this.domainObject].concat(this.path)
|
|
);
|
|
|
|
return (
|
|
!isNavigatedObject &&
|
|
this.path.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;
|
|
},
|
|
plotFirstLeftTickWidth() {
|
|
const firstYAxis = this.yAxes.find((yAxis) => yAxis.id === 1);
|
|
|
|
return firstYAxis ? firstYAxis.tickWidth : 0;
|
|
},
|
|
plotLeftTickWidth() {
|
|
let leftTickWidth = 0;
|
|
this.yAxes.forEach((yAxis) => {
|
|
if (yAxis.id > 2) {
|
|
return;
|
|
}
|
|
|
|
leftTickWidth = leftTickWidth + yAxis.tickWidth;
|
|
});
|
|
const parentLeftTickWidth = this.parentYTickWidth.leftTickWidth;
|
|
|
|
return parentLeftTickWidth || leftTickWidth;
|
|
},
|
|
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,
|
|
tickWidth: 0
|
|
}
|
|
];
|
|
if (this.config.additionalYAxes) {
|
|
this.yAxes = this.yAxes.concat(
|
|
this.config.additionalYAxes.map((yAxis) => {
|
|
return {
|
|
id: yAxis.id,
|
|
seriesCount: 0,
|
|
tickWidth: 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.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.path);
|
|
this.followTimeContext();
|
|
},
|
|
followTimeContext() {
|
|
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);
|
|
|
|
const foundYAxis = this.yAxes.find((yAxis) => yAxis.id === oldAxisId);
|
|
if (foundYAxis.seriesCount === 0) {
|
|
this.onYTickWidthChange({
|
|
width: foundYAxis.tickWidth,
|
|
yAxisId: foundYAxis.id
|
|
});
|
|
}
|
|
},
|
|
|
|
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];
|
|
|
|
if (!this.options.compact) {
|
|
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);
|
|
});
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Aggregate widths of all left and right y axes and send them up to any parent plots
|
|
* @param {Object} tickWidthWithYAxisId - the width and yAxisId of the tick bar
|
|
* @param fromDifferentObject
|
|
*/
|
|
onYTickWidthChange(tickWidthWithYAxisId, fromDifferentObject) {
|
|
const { width, yAxisId } = tickWidthWithYAxisId;
|
|
if (yAxisId) {
|
|
const index = this.yAxes.findIndex((yAxis) => yAxis.id === yAxisId);
|
|
if (fromDifferentObject) {
|
|
// Always accept tick width if it comes from a different object.
|
|
this.yAxes[index].tickWidth = width;
|
|
} else {
|
|
// Otherwise, only accept tick with if it's larger.
|
|
const newWidth = Math.max(width, this.yAxes[index].tickWidth);
|
|
if (width !== this.yAxes[index].tickWidth) {
|
|
this.yAxes[index].tickWidth = newWidth;
|
|
}
|
|
}
|
|
|
|
const id = this.openmct.objects.makeKeyString(this.domainObject.identifier);
|
|
const leftTickWidth = this.yAxes
|
|
.filter((yAxis) => yAxis.id < 3)
|
|
.reduce((previous, current) => {
|
|
return previous + current.tickWidth;
|
|
}, 0);
|
|
const rightTickWidth = this.yAxes
|
|
.filter((yAxis) => yAxis.id > 2)
|
|
.reduce((previous, current) => {
|
|
return previous + current.tickWidth;
|
|
}, 0);
|
|
this.$emit(
|
|
'plot-y-tick-width',
|
|
{
|
|
hasMultipleLeftAxes: this.hasMultipleLeftAxes,
|
|
leftTickWidth,
|
|
rightTickWidth
|
|
},
|
|
id
|
|
);
|
|
}
|
|
},
|
|
|
|
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);
|
|
|
|
// 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() {
|
|
if (!this.marquee) {
|
|
return false;
|
|
}
|
|
|
|
const { start, end } = this.marquee;
|
|
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.path.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>
|