<!--
 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>
