<!--
 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 ref="chart" class="gl-plot-chart-area">
    <canvas
      id="2dContext"
      :style="canvasStyle"
      class="js-overlay-canvas"
      role="img"
      aria-label="Overlay Canvas"
    ></canvas>
    <canvas
      id="webglContext"
      :style="canvasStyle"
      class="js-main-canvas"
      role="img"
      aria-label="Plot Canvas"
    ></canvas>
    <div ref="limitArea" class="js-limit-area" aria-hidden="true">
      <LimitLabel
        v-for="(limitLabel, index) in visibleLimitLabels"
        :key="`limitLabel-${limitLabel.limit.seriesKey}-${index}`"
        :point="limitLabel.point"
        :limit="limitLabel.limit"
      ></LimitLabel>
      <LimitLine
        v-for="(limitLine, index) in visibleLimitLines"
        :key="`limitLine-${limitLine.limit.seriesKey}${index}`"
        :point="limitLine.point"
        :limit="limitLine.limit"
      ></LimitLine>
    </div>
  </div>
</template>

<script>
import mount from 'utils/mount';
import { toRaw } from 'vue';

import configStore from '../configuration/ConfigStore.js';
import PlotConfigurationModel from '../configuration/PlotConfigurationModel.js';
import { DrawLoader } from '../draw/DrawLoader.js';
import eventHelpers from '../lib/eventHelpers.js';
import LimitLabel from './LimitLabel.vue';
import LimitLine from './LimitLine.vue';
import MCTChartAlarmLineSet from './MCTChartAlarmLineSet.js';
import MCTChartAlarmPointSet from './MCTChartAlarmPointSet.js';
import MCTChartLineLinear from './MCTChartLineLinear.js';
import MCTChartLineStepAfter from './MCTChartLineStepAfter.js';
import MCTChartPointSet from './MCTChartPointSet.js';

const MARKER_SIZE = 6.0;
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
const ANNOTATION_SIZE = MARKER_SIZE * 3.0;
const CLEARANCE = 15;
// These attributes are changed in the plot model, but we don't need to react to the changes.
const NO_HANDLING_NEEDED_ATTRIBUTES = {
  label: 'label',
  values: 'values',
  format: 'format',
  color: 'color',
  name: 'name',
  unit: 'unit'
};
// These attributes in turn set one of HANDLED_ATTRIBUTES, so we don't need specific listeners for them - this prevents excessive redraws.
const IMPLICIT_HANDLED_ATTRIBUTES = {
  range: 'range',
  //series stats update y axis stats
  stats: 'stats',
  frozen: 'frozen',
  autoscale: 'autoscale',
  autoscalePadding: 'autoscalePadding',
  logMode: 'logMode',
  yKey: 'yKey'
};
// Attribute changes that we are specifically handling with listeners
const HANDLED_ATTRIBUTES = {
  //X and Y Axis attributes
  key: 'key',
  displayRange: 'displayRange',
  //series attributes
  xKey: 'xKey',
  interpolate: 'interpolate',
  markers: 'markers',
  markerShape: 'markerShape',
  markerSize: 'markerSize',
  alarmMarkers: 'alarmMarkers',
  limitLines: 'limitLines',
  yAxisId: 'yAxisId'
};

export default {
  components: { LimitLine, LimitLabel },
  inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
  props: {
    rectangles: {
      type: Array,
      default() {
        return [];
      }
    },
    highlights: {
      type: Array,
      default() {
        return [];
      }
    },
    annotatedPointsBySeries: {
      type: Object,
      default() {
        return {};
      }
    },
    annotationSelectionsBySeries: {
      type: Object,
      default() {
        return {};
      }
    },
    showLimitLineLabels: {
      type: Object,
      default() {
        return undefined;
      }
    },
    hiddenYAxisIds: {
      type: Array,
      default() {
        return [];
      }
    },
    annotationViewingAndEditingAllowed: {
      type: Boolean,
      required: true
    }
  },
  emits: ['chart-loaded', 'plot-reinitialize-canvas'],
  data() {
    return {
      visibleLimitLabels: [],
      visibleLimitLines: []
    };
  },
  computed: {
    canvasStyle() {
      return {
        position: 'absolute',
        background: 'none',
        width: '100%',
        height: '100%'
      };
    }
  },
  watch: {
    highlights: {
      handler() {
        this.scheduleDraw();
      },
      deep: true
    },
    annotatedPointsBySeries: {
      handler() {
        this.scheduleDraw();
      }
    },
    annotationSelectionsBySeries: {
      handler() {
        this.scheduleDraw();
      }
    },
    rectangles: {
      handler() {
        this.scheduleDraw();
      },
      deep: true
    },
    showLimitLineLabels() {
      this.updateLimitLines();
    },
    hiddenYAxisIds: {
      handler() {
        this.hiddenYAxisIds.forEach((id) => {
          this.resetYOffsetAndSeriesDataForYAxis(id);
        });
        this.scheduleDraw(true);
      },
      deep: true
    }
  },
  mounted() {
    this.chartVisible = true;
    this.chartContainer = this.$refs.chart;
    this.drawnOnce = false;
    const rootContainer = this.openmct.element;
    const options = {
      root: rootContainer
    };
    this.visibilityObserver = new IntersectionObserver(this.visibilityChanged, options);
    eventHelpers.extend(this);
    this.seriesModels = [];
    this.config = this.getConfig();
    this.isDestroyed = false;
    this.lines = [];
    this.limitLines = [];
    this.pointSets = [];
    this.alarmSets = [];
    const yAxisId = this.config.yAxis.get('id');
    this.offset = {
      [yAxisId]: {}
    };
    this.listenTo(
      this.config.yAxis,
      `change:${HANDLED_ATTRIBUTES.displayRange}`,
      this.scheduleDraw
    );
    this.listenTo(
      this.config.yAxis,
      `change:${HANDLED_ATTRIBUTES.key}`,
      this.resetYOffsetAndSeriesDataForYAxis.bind(this, yAxisId),
      this
    );
    this.listenTo(this.config.yAxis, 'change', this.redrawIfNotAlreadyHandled);
    if (this.config.additionalYAxes.length) {
      this.config.additionalYAxes.forEach((yAxis) => {
        const id = yAxis.get('id');
        this.offset[id] = {};
        this.listenTo(yAxis, `change:${HANDLED_ATTRIBUTES.displayRange}`, this.scheduleDraw);
        this.listenTo(
          yAxis,
          `change:${HANDLED_ATTRIBUTES.key}`,
          this.resetYOffsetAndSeriesDataForYAxis.bind(this, id),
          this
        );
        this.listenTo(yAxis, 'change', this.redrawIfNotAlreadyHandled);
      });
    }

    this.seriesElements = new WeakMap();
    this.seriesLimits = new WeakMap();

    const canvasReadyForDrawing = this.readyCanvasForDrawing();
    if (canvasReadyForDrawing) {
      this.draw();
    }

    this.listenTo(this.config.series, 'add', this.onSeriesAdd, this);
    this.listenTo(this.config.series, 'remove', this.onSeriesRemove, this);

    this.listenTo(this.config.xAxis, 'change:displayRange', this.scheduleDraw);
    this.listenTo(this.config.xAxis, 'change', this.redrawIfNotAlreadyHandled);
    this.config.series.forEach(this.onSeriesAdd, this);
    this.$emit('chart-loaded');

    this.handleWindowResize = _.debounce(this.handleWindowResize, 250);
    this.chartResizeObserver = new ResizeObserver(this.handleWindowResize);
    this.chartResizeObserver.observe(this.$parent.$refs.chartContainer);
  },
  beforeUnmount() {
    this.destroy();
    this.visibilityObserver.unobserve(this.chartContainer);
  },
  methods: {
    handleWindowResize() {
      this.scheduleDraw(true);
    },
    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
        });
        configStore.add(configId, config);
      }

      return config;
    },
    visibilityChanged([entry]) {
      // Per https://github.com/nasa/openmct/issues/7405, we only want to draw when the chart is visible.
      // and we need to use the Open MCT root element as the root of the intersection observer.
      if (entry.target === this.chartContainer) {
        const wasVisible = this.chartVisible;
        const isNowVisible = entry.isIntersecting;
        const chartInOverlayWindow = this.chartContainer?.closest('.js-overlay') !== null;

        if (!isNowVisible && !chartInOverlayWindow) {
          this.chartVisible = false;
          this.destroyCanvas();
        } else if (!isNowVisible && chartInOverlayWindow) {
          this.chartVisible = true;
        } else if (!wasVisible && isNowVisible) {
          this.chartVisible = true;
          // rebuild the chart
          this.buildCanvasElements();
          const canvasInitialized = this.readyCanvasForDrawing();
          if (canvasInitialized) {
            this.draw();
          }
          this.$emit('plot-reinitialize-canvas');
        } else {
          this.chartVisible = isNowVisible;
        }
      }
    },
    reDraw(newXKey, oldXKey, series) {
      this.changeInterpolate(newXKey, oldXKey, series);
      this.changeMarkers(newXKey, oldXKey, series);
      this.changeAlarmMarkers(newXKey, oldXKey, series);
      this.changeLimitLines(newXKey, oldXKey, series);
    },
    onSeriesAdd(series, index) {
      this.seriesModels[index] = series;
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.xKey}`, this.reDraw, this);
      this.listenTo(
        series,
        `change:${HANDLED_ATTRIBUTES.interpolate}`,
        this.changeInterpolate,
        this
      );
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markers}`, this.changeMarkers, this);
      this.listenTo(
        series,
        `change:${HANDLED_ATTRIBUTES.alarmMarkers}`,
        this.changeAlarmMarkers,
        this
      );
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.limitLines}`, this.changeLimitLines, this);
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.yAxisId}`, this.resetAxisAndRedraw, this);
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerShape}`, this.scheduleDraw, this);
      this.listenTo(series, `change:${HANDLED_ATTRIBUTES.markerSize}`, this.scheduleDraw, this);
      this.listenTo(series, 'change', this.redrawIfNotAlreadyHandled);
      this.listenTo(series, 'add', this.onAddPoint);
      this.makeChartElement(series);
      this.makeLimitLines(series);
    },
    onSeriesRemove(seriesToRemove) {
      this.stopListening(seriesToRemove);
      this.removeChartElement(seriesToRemove);
      this.scheduleDraw();

      const seriesIndexToRemove = this.seriesModels.findIndex(
        (series) => series.keyString === seriesToRemove.keyString
      );
      this.seriesModels.splice(seriesIndexToRemove, 1);
    },
    onAddPoint(point, insertIndex, series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const seriesYAxisId = series.get('yAxisId');
      const xRange = this.config.xAxis.get('displayRange');

      let yRange;
      if (seriesYAxisId === mainYAxisId) {
        yRange = this.config.yAxis.get('displayRange');
      } else {
        yRange = this.config.additionalYAxes
          .find((yAxis) => yAxis.get('id') === seriesYAxisId)
          .get('displayRange');
      }

      const xValue = series.getXVal(point);
      const yValue = series.getYVal(point);

      // if user is not looking at data within the current bounds, don't draw the point
      if (
        xValue > xRange.min &&
        xValue < xRange.max &&
        yValue > yRange.min &&
        yValue < yRange.max
      ) {
        this.scheduleDraw();
      }
    },
    changeInterpolate(mode, o, series) {
      if (mode === o) {
        return;
      }

      const elements = this.seriesElements.get(toRaw(series));
      elements.lines.forEach(function (line) {
        this.lines.splice(this.lines.indexOf(line), 1);
        line.destroy();
      }, this);
      elements.lines = [];

      const newLine = this.lineForSeries(series);
      if (newLine) {
        elements.lines.push(newLine);
        this.lines.push(newLine);
      }
    },
    changeAlarmMarkers(mode, o, series) {
      if (mode === o) {
        return;
      }

      const elements = this.seriesElements.get(toRaw(series));
      if (elements.alarmSet) {
        elements.alarmSet.destroy();
        this.alarmSets.splice(this.alarmSets.indexOf(elements.alarmSet), 1);
      }

      elements.alarmSet = this.alarmPointSetForSeries(series);
      if (elements.alarmSet) {
        this.alarmSets.push(elements.alarmSet);
      }
    },
    changeMarkers(mode, o, series) {
      if (mode === o) {
        return;
      }

      const elements = this.seriesElements.get(toRaw(series));
      elements.pointSets.forEach(function (pointSet) {
        this.pointSets.splice(this.pointSets.indexOf(pointSet), 1);
        pointSet.destroy();
      }, this);
      elements.pointSets = [];

      const pointSet = this.pointSetForSeries(series);
      if (pointSet) {
        elements.pointSets.push(pointSet);
        this.pointSets.push(pointSet);
      }
    },
    changeLimitLines(showLimitLines, oldShowLimitLines, series) {
      if (showLimitLines === oldShowLimitLines) {
        return;
      }

      this.makeLimitLines(series);
      this.updateLimitLines();
    },
    resetAxisAndRedraw(newYAxisId, oldYAxisId, series) {
      if (!oldYAxisId) {
        return;
      }

      //Remove the old chart elements for the series since their offsets are pointing to the old y axis
      this.removeChartElement(series);
      this.resetYOffsetAndSeriesDataForYAxis(oldYAxisId);

      //Make the chart elements again for the new y-axis and offset
      this.makeChartElement(series);
      this.makeLimitLines(series);
      this.scheduleDraw(true);
    },
    destroy() {
      this.destroyCanvas();
      this.isDestroyed = true;
      this.lines.forEach((line) => line.destroy());
      this.limitLines.forEach((line) => line.destroy());
      this.pointSets.forEach((pointSet) => pointSet.destroy());
      this.alarmSets.forEach((alarmSet) => alarmSet.destroy());
      DrawLoader.releaseDrawAPI(this.drawAPI);
      if (this.chartResizeObserver) {
        this.chartResizeObserver.disconnect();
      }
    },
    resetYOffsetAndSeriesDataForYAxis(yAxisId) {
      delete this.offset[yAxisId].y;
      delete this.offset[yAxisId].xVal;
      delete this.offset[yAxisId].yVal;
      delete this.offset[yAxisId].xKey;
      delete this.offset[yAxisId].yKey;

      this.resetResetChartElements(yAxisId);
    },
    resetResetChartElements(yAxisId) {
      const lines = this.lines.filter(this.matchByYAxisIdExcludingVisibility.bind(this, yAxisId));
      lines.forEach(function (line) {
        line.reset();
      });
      const limitLines = this.limitLines.filter(
        this.matchByYAxisIdExcludingVisibility.bind(this, yAxisId)
      );
      limitLines.forEach(function (line) {
        line.reset();
      });
      const pointSets = this.pointSets.filter(
        this.matchByYAxisIdExcludingVisibility.bind(this, yAxisId)
      );
      pointSets.forEach(function (pointSet) {
        pointSet.reset();
      });
    },
    setOffset(offsetPoint, index, series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const yAxisId = series.get('yAxisId') || mainYAxisId;
      if (this.offset[yAxisId].x && this.offset[yAxisId].y) {
        return;
      }

      const offsets = {
        x: series.getXVal(offsetPoint),
        y: series.getYVal(offsetPoint)
      };

      this.offset[yAxisId].x = function (x) {
        return x - offsets.x;
      }.bind(this);
      this.offset[yAxisId].y = function (y) {
        return y - offsets.y;
      }.bind(this);
      this.offset[yAxisId].xVal = function (point, pSeries) {
        return this.offset[yAxisId].x(pSeries.getXVal(point));
      }.bind(this);
      this.offset[yAxisId].yVal = function (point, pSeries) {
        return this.offset[yAxisId].y(pSeries.getYVal(point));
      }.bind(this);
    },
    destroyCanvas() {
      if (this.isDestroyed) {
        return;
      }
      this.stopListening(this.drawAPI);
      DrawLoader.releaseDrawAPI(this.drawAPI);
      if (this.chartContainer) {
        const canvasElements = this.chartContainer.querySelectorAll('canvas');
        canvasElements.forEach((canvas) => {
          canvas.parentNode.removeChild(canvas);
        });
      }
    },
    readyCanvasForDrawing() {
      const canvasEls = this.chartContainer.querySelectorAll('canvas');
      const mainCanvas = canvasEls[1];
      const overlayCanvas = canvasEls[0];
      this.canvas = mainCanvas;
      this.overlay = overlayCanvas;
      this.drawAPI = DrawLoader.getDrawAPI(mainCanvas, overlayCanvas);
      if (this.drawAPI?.on) {
        this.listenTo(this.drawAPI, 'error', this.fallbackToCanvas, this);
      }

      return Boolean(this.drawAPI);
    },
    buildCanvasElements() {
      const div = document.createElement('div');
      div.innerHTML = `
      <canvas style="position: absolute; background: none; width: 100%; height: 100%;" class="js-overlay-canvas"></canvas>
      <canvas style="position: absolute; background: none; width: 100%; height: 100%;" class="js-main-canvas"></canvas>
      `;
      const mainCanvas = div.querySelectorAll('canvas')[1];
      const overlayCanvas = div.querySelectorAll('canvas')[0];
      this.chartContainer.appendChild(mainCanvas, this.canvas);
      this.canvas = mainCanvas;
      this.chartContainer.appendChild(overlayCanvas, this.overlay);
      this.overlay = overlayCanvas;
    },
    fallbackToCanvas() {
      console.warn(`📈 fallback to 2D canvas`);
      this.destroyCanvas();
      this.buildCanvasElements();
      this.drawAPI = DrawLoader.getFallbackDrawAPI(this.canvas, this.overlay);
      this.$emit('plot-reinitialize-canvas');
    },
    removeChartElement(series) {
      const elements = this.seriesElements.get(toRaw(series));

      elements.lines.forEach(function (line) {
        this.lines.splice(this.lines.indexOf(line), 1);
        line.destroy();
      }, this);
      elements.pointSets.forEach(function (pointSet) {
        this.pointSets.splice(this.pointSets.indexOf(pointSet), 1);
        pointSet.destroy();
      }, this);
      if (elements.alarmSet) {
        elements.alarmSet.destroy();
        this.alarmSets.splice(this.alarmSets.indexOf(elements.alarmSet), 1);
      }

      this.seriesElements.delete(toRaw(series));

      this.clearLimitLines(series);
    },
    lineForSeries(series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const yAxisId = series.get('yAxisId') || mainYAxisId;
      let offset = this.offset[yAxisId];

      if (series.get('interpolate') === 'linear') {
        return new MCTChartLineLinear(series, this, offset);
      }

      if (series.get('interpolate') === 'stepAfter') {
        return new MCTChartLineStepAfter(series, this, offset);
      }
    },
    limitLineForSeries(series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const yAxisId = series.get('yAxisId') || mainYAxisId;
      let offset = this.offset[yAxisId];

      return new MCTChartAlarmLineSet(series, this, offset, this.openmct.time.getBounds());
    },
    pointSetForSeries(series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const yAxisId = series.get('yAxisId') || mainYAxisId;
      let offset = this.offset[yAxisId];

      if (series.get('markers')) {
        return new MCTChartPointSet(series, this, offset);
      }
    },
    alarmPointSetForSeries(series) {
      const mainYAxisId = this.config.yAxis.get('id');
      const yAxisId = series.get('yAxisId') || mainYAxisId;
      let offset = this.offset[yAxisId];

      if (series.get('alarmMarkers')) {
        return new MCTChartAlarmPointSet(series, this, offset);
      }
    },
    makeChartElement(series) {
      const elements = {
        lines: [],
        pointSets: [],
        limitLines: []
      };

      const line = this.lineForSeries(series);
      if (line) {
        elements.lines.push(line);
        this.lines.push(line);
      }

      const pointSet = this.pointSetForSeries(series);
      if (pointSet) {
        elements.pointSets.push(pointSet);
        this.pointSets.push(pointSet);
      }

      elements.alarmSet = this.alarmPointSetForSeries(series);
      if (elements.alarmSet) {
        this.alarmSets.push(elements.alarmSet);
      }

      this.seriesElements.set(toRaw(series), elements);
    },
    makeLimitLines(series) {
      this.clearLimitLines(series);

      if (!series || !series.get('limitLines')) {
        return;
      }

      const limitElements = {
        limitLines: []
      };

      const limitLine = this.limitLineForSeries(series);
      if (limitLine) {
        limitElements.limitLines.push(limitLine);
        this.limitLines.push(limitLine);
      }

      this.seriesLimits.set(toRaw(series), limitElements);
    },
    clearLimitLines(series) {
      const seriesLimits = this.seriesLimits.get(toRaw(series));

      if (seriesLimits) {
        seriesLimits.limitLines.forEach(function (line) {
          this.limitLines.splice(this.limitLines.indexOf(line), 1);
          line.destroy();
        }, this);

        this.seriesLimits.delete(toRaw(series));
      }
    },
    canDraw(yAxisId) {
      if (!this.offset[yAxisId] || !this.offset[yAxisId].x || !this.offset[yAxisId].y) {
        return false;
      }

      return true;
    },
    redrawIfNotAlreadyHandled(attribute, value, oldValue) {
      if (Object.keys(HANDLED_ATTRIBUTES).includes(attribute) && oldValue) {
        return;
      }

      if (Object.keys(IMPLICIT_HANDLED_ATTRIBUTES).includes(attribute) && oldValue) {
        return;
      }

      if (Object.keys(NO_HANDLING_NEEDED_ATTRIBUTES).includes(attribute) && oldValue) {
        return;
      }

      this.scheduleDraw(true);
    },
    scheduleDraw(updateLimitLines) {
      if (!this.drawScheduled) {
        const called = this.renderWhenVisible(this.draw.bind(this, updateLimitLines));
        this.drawScheduled = called;
        if (!this.drawnOnce && called) {
          this.drawnOnce = true;
          this.visibilityObserver.observe(this.chartContainer);
        }
      }
    },
    draw(updateLimitLines) {
      this.drawScheduled = false;
      if (this.isDestroyed || !this.chartVisible) {
        return;
      }

      this.drawAPI.clear();
      const mainYAxisId = this.config.yAxis.get('id');
      //There has to be at least one yAxis
      const yAxisIds = [mainYAxisId].concat(
        this.config.additionalYAxes.map((yAxis) => yAxis.get('id'))
      );

      // Repeat drawing for all yAxes
      yAxisIds.filter(this.canDraw).forEach((id, yAxisIndex) => {
        this.updateViewport(id);
        this.drawSeries(id);
        if (yAxisIndex === 0) {
          this.drawRectangles(id);
        }

        this.drawHighlights(id);
        // only draw these in fixed time mode or plot is paused
        if (this.annotationViewingAndEditingAllowed) {
          this.prepareToDrawAnnotatedPoints(id);
          this.prepareToDrawAnnotationSelections(id);
        }
      });
      // We must do the limit line drawing after the drawAPI has been cleared (which sets the height and width of the draw API)
      // and the viewport is updated so that we have the right height/width for limit line x and y calculations
      if (updateLimitLines) {
        this.updateLimitLines();
      }
    },
    updateViewport(yAxisId) {
      if (!this.chartVisible) {
        return;
      }
      const mainYAxisId = this.config.yAxis.get('id');
      const xRange = this.config.xAxis.get('displayRange');
      let yRange;
      if (yAxisId === mainYAxisId) {
        yRange = this.config.yAxis.get('displayRange');
      } else {
        if (this.config.additionalYAxes.length) {
          const yAxisForId = this.config.additionalYAxes.find(
            (yAxis) => yAxis.get('id') === yAxisId
          );
          yRange = yAxisForId.get('displayRange');
        }
      }

      if (!xRange || !yRange) {
        return;
      }

      const dimensions = [xRange.max - xRange.min, yRange.max - yRange.min];

      let origin;
      origin = [this.offset[yAxisId].x(xRange.min), this.offset[yAxisId].y(yRange.min)];

      this.drawAPI.setDimensions(dimensions, origin);
    },
    // match items by their yAxisId, but don't care if the series is hidden or not.
    matchByYAxisIdExcludingVisibility() {
      const args = Array.from(arguments).slice(0, 4);

      return this.matchByYAxisId(...args, true);
    },
    matchByYAxisId(id, item, index, items, excludeVisibility = false) {
      const mainYAxisId = this.config.yAxis.get('id');
      let matchesId = false;
      const axisSeriesAreVisible = excludeVisibility || this.hiddenYAxisIds.indexOf(id) < 0;
      const series = item.series;
      if (axisSeriesAreVisible && series) {
        const seriesYAxisId = series.get('yAxisId') || mainYAxisId;
        matchesId = seriesYAxisId === id;
      }

      return matchesId;
    },
    drawSeries(id) {
      const lines = this.lines.filter(this.matchByYAxisId.bind(this, id));
      lines.forEach(this.drawLine, this);
      const pointSets = this.pointSets.filter(this.matchByYAxisId.bind(this, id));
      pointSets.forEach(this.drawPoints, this);
      const alarmSets = this.alarmSets.filter(this.matchByYAxisId.bind(this, id));
      alarmSets.forEach(this.drawAlarmPoints, this);
    },
    updateLimitLines() {
      //reset
      this.visibleLimitLabels = [];
      this.visibleLimitLines = [];

      this.config.series.models.forEach((series) => {
        const yAxisId = series.get('yAxisId');

        if (this.hiddenYAxisIds.indexOf(yAxisId) < 0) {
          this.updateLimitLinesForSeries(yAxisId, series);
        }
      });
    },
    updateLimitLinesForSeries(yAxisId, series) {
      if (!this.canDraw(yAxisId)) {
        return;
      }

      this.updateViewport(yAxisId);

      if (!this.drawAPI.origin) {
        return;
      }
      let limitPointOverlap = [];

      this.limitLines.forEach((limitLine) => {
        limitLine.limits.forEach((limit) => {
          if (series.keyString !== limit.seriesKey) {
            return;
          }

          const showLabels = this.showLabels(limit.seriesKey);
          if (showLabels) {
            const overlap = this.getLimitOverlap(limit, limitPointOverlap);
            limitPointOverlap.push(overlap);
            this.visibleLimitLabels.push(this.getLimitProps(limit, overlap));
          }

          this.visibleLimitLines.push(this.getLimitElementProps(limit));
        }, this);
      });
    },
    showLabels(seriesKey) {
      return this.showLimitLineLabels?.seriesKey === seriesKey;
    },
    getLimitElementProps(limit) {
      let point = {
        left: 0,
        top: this.drawAPI.y(limit.point.y)
      };

      return {
        point,
        limit
      };
    },
    getLimitElement(limit) {
      let point = {
        left: 0,
        top: this.drawAPI.y(limit.point.y)
      };
      const { vNode, destroy } = mount(LimitLine, {
        props: {
          point,
          limit
        }
      });

      return {
        el: vNode.el,
        destroy
      };
    },
    getLimitOverlap(limit, overlapMap) {
      //calculate if limit lines are too close to each other
      let limitTop = this.drawAPI.y(limit.point.y);
      const needsVerticalAdjustment = limitTop - CLEARANCE <= 0;
      let needsHorizontalAdjustment = false;
      overlapMap.forEach((value) => {
        let diffTop;
        if (limitTop > value.overlapTop) {
          diffTop = limitTop - value.overlapTop;
        } else {
          diffTop = value.overlapTop - limitTop;
        }

        //need to compare +ves to +ves and -ves to -ves
        if (
          !needsHorizontalAdjustment &&
          Math.abs(diffTop) <= CLEARANCE &&
          value.needsHorizontalAdjustment !== true
        ) {
          needsHorizontalAdjustment = true;
        }
      });

      return {
        needsHorizontalAdjustment,
        needsVerticalAdjustment,
        overlapTop: limitTop
      };
    },
    getLimitProps(limit, overlap) {
      let point = {
        left: 0,
        top: this.drawAPI.y(limit.point.y)
      };
      return {
        limit: Object.assign({}, overlap, limit),
        point
      };
    },
    getLimitLabel(limit, overlap) {
      let point = {
        left: 0,
        top: this.drawAPI.y(limit.point.y)
      };
      const { vNode, destroy } = mount(LimitLabel, {
        props: {
          limit: Object.assign({}, overlap, limit),
          point
        }
      });

      return {
        el: vNode.el,
        destroy
      };
    },
    drawAlarmPoints(alarmSet) {
      this.drawAPI.drawLimitPoints(
        alarmSet.points,
        alarmSet.series.get('color').asRGBAArray(),
        alarmSet.series.get('markerSize')
      );
    },
    drawPoints(chartElement) {
      this.drawAPI.drawPoints(
        chartElement.getBuffer(),
        chartElement.color().asRGBAArray(),
        chartElement.count,
        chartElement.series.get('markerSize'),
        chartElement.series.get('markerShape')
      );
    },
    drawLine(chartElement, disconnected) {
      if (chartElement) {
        this.drawAPI.drawLine(
          chartElement.getBuffer(),
          chartElement.color().asRGBAArray(),
          chartElement.count,
          disconnected
        );
      }
    },
    prepareToDrawAnnotatedPoints(yAxisId) {
      if (this.annotatedPointsBySeries && Object.values(this.annotatedPointsBySeries).length) {
        const uniquePointsToDraw = new Set();

        Object.keys(this.annotatedPointsBySeries).forEach((seriesKeyString) => {
          const seriesModel = this.getSeries(seriesKeyString);
          const matchesYAxis = this.matchByYAxisId(yAxisId, { series: seriesModel });
          if (!matchesYAxis) {
            return;
          }
          // annotation points are all within range (checked in MctPlot with FlatBush), so we don't need to check
          const annotatedPointBuffer = new Float32Array(
            this.annotatedPointsBySeries[seriesKeyString].length * 2
          );
          Object.values(this.annotatedPointsBySeries[seriesKeyString]).forEach(
            (annotatedPoint, index) => {
              const canvasXValue = this.offset[yAxisId].xVal(annotatedPoint.point, seriesModel);
              const canvasYValue = this.offset[yAxisId].yVal(annotatedPoint.point, seriesModel);
              const drawnPointKey = `${canvasXValue}|${canvasYValue}`;
              if (!uniquePointsToDraw.has(drawnPointKey)) {
                annotatedPointBuffer[index * 2] = canvasXValue;
                annotatedPointBuffer[index * 2 + 1] = canvasYValue;
                uniquePointsToDraw.add(drawnPointKey);
              }
            }
          );
          this.drawAnnotatedPoints(seriesModel, annotatedPointBuffer);
        });
      }
    },
    drawAnnotatedPoints(seriesModel, annotatedPointBuffer) {
      if (annotatedPointBuffer && seriesModel) {
        const color = seriesModel.get('color').asRGBAArray();
        // set transparency
        color[3] = 0.15;
        const pointCount = annotatedPointBuffer.length / 2;
        const shape = seriesModel.get('markerShape');

        this.drawAPI.drawPoints(annotatedPointBuffer, color, pointCount, ANNOTATION_SIZE, shape);
      }
    },
    prepareToDrawAnnotationSelections(yAxisId) {
      if (
        this.annotationSelectionsBySeries &&
        Object.keys(this.annotationSelectionsBySeries).length
      ) {
        Object.keys(this.annotationSelectionsBySeries).forEach((seriesKeyString) => {
          const seriesModel = this.getSeries(seriesKeyString);
          const matchesYAxis = this.matchByYAxisId(yAxisId, { series: seriesModel });
          if (matchesYAxis) {
            const annotationSelectionBuffer = new Float32Array(
              this.annotationSelectionsBySeries[seriesKeyString].length * 2
            );
            Object.values(this.annotationSelectionsBySeries[seriesKeyString]).forEach(
              (annotatedSelectedPoint, index) => {
                const canvasXValue = this.offset[yAxisId].xVal(
                  annotatedSelectedPoint.point,
                  seriesModel
                );
                const canvasYValue = this.offset[yAxisId].yVal(
                  annotatedSelectedPoint.point,
                  seriesModel
                );
                annotationSelectionBuffer[index * 2] = canvasXValue;
                annotationSelectionBuffer[index * 2 + 1] = canvasYValue;
              }
            );
            this.drawAnnotationSelections(seriesModel, annotationSelectionBuffer);
          }
        });
      }
    },
    drawAnnotationSelections(seriesModel, annotationSelectionBuffer) {
      const color = [255, 255, 255, 1]; // white
      const pointCount = annotationSelectionBuffer.length / 2;
      const shape = seriesModel.get('markerShape');

      this.drawAPI.drawPoints(annotationSelectionBuffer, color, pointCount, ANNOTATION_SIZE, shape);
    },
    drawHighlights(yAxisId) {
      if (this.highlights && this.highlights.length) {
        const highlights = this.highlights.filter((highlight) => {
          const series = this.getSeries(highlight.seriesKeyString);
          return this.matchByYAxisId.bind(yAxisId, { series });
        });
        highlights.forEach(this.drawHighlight.bind(this, yAxisId), this);
      }
    },
    getSeries(keyStringToFind) {
      const foundSeries = this.seriesModels.find((series) => {
        return series.keyString === keyStringToFind;
      });
      return foundSeries;
    },
    drawHighlight(yAxisId, highlight) {
      const series = this.getSeries(highlight.seriesKeyString);
      const points = new Float32Array([
        this.offset[yAxisId].xVal(highlight.point, series),
        this.offset[yAxisId].yVal(highlight.point, series)
      ]);

      const color = series.get('color').asRGBAArray();
      const pointCount = 1;
      const shape = series.get('markerShape');

      this.drawAPI.drawPoints(points, color, pointCount, HIGHLIGHT_SIZE, shape);
    },
    drawRectangles(yAxisId) {
      if (this.rectangles) {
        this.rectangles.forEach(this.drawRectangle.bind(this, yAxisId), this);
      }
    },
    drawRectangle(yAxisId, rect) {
      if (!rect.start.yAxisIds || !rect.end.yAxisIds) {
        return;
      }

      const startYIndex = rect.start.yAxisIds.findIndex((id) => id === yAxisId);
      const endYIndex = rect.end.yAxisIds.findIndex((id) => id === yAxisId);
      if (rect.start.y[startYIndex] && rect.end.y[endYIndex]) {
        this.drawAPI.drawSquare(
          [this.offset[yAxisId].x(rect.start.x), this.offset[yAxisId].y(rect.start.y[startYIndex])],
          [this.offset[yAxisId].x(rect.end.x), this.offset[yAxisId].y(rect.end.y[endYIndex])],
          rect.color
        );
      }
    }
  }
};
</script>
