<template>
  <div :id="chartID" class="chart" :style="cssVars" :height="height">
    <resize-observer @notify="handleResize" />
    <svg
      :id="chartID + '_SVGDaysGradient'"
      class="chart-gradient"
      :height="spanY"
      :width="width"
    >
      <image
        :id="chartID + '_Gradient'"
        :href="sunImage"
        :width="spanX"
        :height="spanY"
        :x="rangeX[0]"
        preserveAspectRatio="none"
      />
      <rect :width="margin.left" :height="spanY" />
      <rect :width="margin.right" :height="spanY" :x="rangeX[1]" />
    </svg>
    <canvas
      :id="chartID + '_CanvasPoints'"
      class="chart-points"
      :height="height"
      :width="width"
    />
    <svg :id="chartID + '_SVGAxesActive'" class="chart-axes" :height="height">
      <g :id="chartID + '_XAxis'" />
      <g :id="chartID + '_YAxis'" />
      <g :id="chartID + '_HoveredPoint'" visibility="hidden">
        <line class="hoveredPoint" />
        <circle :r="datapointRadius + 0.5" class="hoveredPoint" />
      </g>
      <g :id="chartID + '_ActivePoint'" visibility="hidden">
        <circle :r="datapointRadius + 0.5" class="activePoint" />
        <line class="activePoint" />
      </g>
    </svg>
  </div>
</template>

<script>
import * as d3 from 'd3'

export default {
  props: {
    chartID: {
      type: String,
      required: true
    },
    stickID: {
      type: String,
      required: true
    },
    cameraID: {
      type: String,
      required: true
    },
    height: {
      type: Number,
      required: true
    }
  },
  data() {
    return {
      width: 1000,
      margin: { top: 15, bottom: 20, left: 30, right: 20 },
      scaleExtent: [1, Infinity],
      datapointLineWidth: 1
    }
  },
  computed: {
    // --- D3 chart element accessors ---
    $chart() {
      return d3.select('#' + this.chartID)
    },
    $svgAxesActive() {
      return d3.select('#' + this.chartID + '_SVGAxesActive')
    },
    $canvasPoints() {
      return d3.select('#' + this.chartID + '_CanvasPoints')
    },
    $xAxis() {
      return d3.select('#' + this.chartID + '_XAxis')
    },
    $yAxis() {
      return d3.select('#' + this.chartID + '_YAxis')
    },
    $hoveredPoint() {
      return d3.select('#' + this.chartID + '_HoveredPoint')
    },
    $activePoint() {
      return d3.select('#' + this.chartID + '_ActivePoint')
    },
    $gradient() {
      return d3.select('#' + this.chartID + '_Gradient')
    },
    // ---

    // --- SCSS variables ---
    cssVars() {
      return {
        '--chart-margin-left': this.margin.left + 'px',
        '--chart-margin-top': this.margin.top + 'px'
      }
    },
    // ---

    // --- Canvas context ---
    context() {
      return this.$canvasPoints.node().getContext('2d')
    },
    // ---

    // --- Precomputed helpers ---
    rangeX() {
      return [this.margin.left, this.width - this.margin.right]
    },
    spanX() {
      return this.rangeX[1] - this.rangeX[0]
    },
    rangeY() {
      return [this.height - this.margin.bottom, this.margin.top]
    },
    spanY() {
      return this.rangeY[0] - this.rangeY[1]
    },
    maxSnowHeightRounded() {
      return Math.round(Math.ceil(this.maxSnowHeight / 10)) * 10
    },
    datapointRadius() {
      const zoomScale =
        1 / (this.zoomState.selection[1] - this.zoomState.selection[0])
      if (zoomScale > 40) {
        return 5
      }
      return zoomScale / 20 + 2
    },
    // ---

    // --- Scales ---
    xScale() {
      return d3
        .scaleUtc()
        .domain(this.timespan)
        .range(this.rangeX)
    },
    xScaleZoomed() {
      const transform = this.normalizedSelection2Transform(
        this.zoomState.selection
      )
      return transform.rescaleX(this.xScale)
    },
    yScale() {
      return d3
        .scaleLinear()
        .domain([0, this.maxSnowHeightRounded])
        .range(this.rangeY)
    },
    // ---

    // --- Vuex store data ---
    chartData() {
      return this.$store.getters.chartData(this.cameraID)
    },
    redrawTrigger() {
      return this.$store.state.redrawTrigger
    },
    sunImage() {
      return this.$store.getters.sunImage
    },
    timespan() {
      return [
        this.$store.getters.timespanStart,
        this.$store.getters.timespanEnd
      ]
    },
    maxSnowHeight() {
      return this.$store.getters.maxSnowHeight
    },
    zoomState() {
      // Returns:
      // zoomState - {selection: [0, 1], zoomingID: cameraID_stickID}
      // selection: Start and end points of the viewport.
      return this.$store.getters.zoomState
    },
    activeImageName: {
      get() {
        if (this.$store.getters.activeStick === this.stickID) {
          return this.$store.getters.activeImageName(this.cameraID)
        }
        return null
      },
      set(imageName) {
        this.$store.dispatch('setActivePoint', {
          stickID: this.stickID,
          cameraID: this.cameraID,
          imageName
        })
      }
    },

    // Check visibility of the chart. No need to rerender hidden chart.
    isVisible() {
      const statusGUI = this.$store.getters.stickStatusGUI(this.stickID)
      const primaryCamera = this.$store.getters.primaryCamera(this.stickID)
      switch (statusGUI) {
        case 'minimized':
          return false
        case 'compact':
          if (primaryCamera === this.cameraID) {
            return true
          } else {
            return false
          }
        case 'full':
          return true
        default:
          return true
      }
    },
    // ---

    // --- Quadtree ---
    quadtree() {
      if (this.chartData) {
        return d3
          .quadtree()
          .x(d => {
            // Ask for x coordinate in the canvas coordinate system.
            let dateTime = this.chartData[d].dateTime
            return this.xScaleZoomed(new Date(dateTime))
          })
          .y(d => {
            return this.yScale(
              this.chartData[d].sticks[this.stickID].snowHeight
            )
          })
          .extent([
            [this.margin.left, 0],
            [this.width - this.margin.right, this.height]
          ])
          .addAll(Object.keys(this.chartData))
      }
      return null
    },
    // ---

    // --- Zoom ---
    zoom() {
      return d3
        .zoom()
        .scaleExtent(this.scaleExtent)
        .extent([
          [this.margin.left, 0],
          [this.width - this.margin.right, this.height]
        ])
        .translateExtent([
          [this.margin.left, 0],
          [this.width - this.margin.right, 0]
        ])
        .on('zoom', this.zoomed)
    },
    // ---

    // --- Filters ---
    // filterImageProcessed() {
    //   return this.$store.state.filters.imageProcessed
    // },
    filterIsDay() {
      return this.$store.state.filters.isDay
    },
    filterSnowDetected() {
      return this.$store.state.filters.snowDetected
    },
    filterStickFound() {
      return this.$store.state.filters.stickFound
    },
    filterMinSnowHeight() {
      return this.$store.state.filters.minSnowHeight
    },
    filterMaxSnowHeight() {
      return this.$store.state.filters.maxSnowHeight
    },
    filterImageSticksEdited() {
      return this.$store.state.filters.imageSticksEdited
    },
    filterImageDiscarded() {
      return this.$store.state.filters.imageDiscarded
    }
    // filterImageUsedForSvmTraining() {
    //   return this.$store.state.filters.imageUsedForSvmTraining
    // }
    // ---
  },
  watch: {
    // filterImageProcessed() {
    //   if (this.isVisible) {
    //     this.redrawPoints(this.xScaleZoomed)
    //   }
    // },
    filterIsDay() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterSnowDetected() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterStickFound() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterMinSnowHeight() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterMaxSnowHeight() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterImageSticksEdited() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    filterImageDiscarded() {
      if (this.isVisible) {
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    // filterImageUsedForSvmTraining() {
    //   if (this.isVisible) {
    //     this.redrawPoints(this.xScaleZoomed)
    //   }
    // },
    activeImageName(imageName) {
      if (imageName) {
        this.$activePoint.attr('visibility', 'visible')
        this.drawActivePoint(imageName, this.xScaleZoomed, this.yScale)
      } else {
        this.$activePoint.attr('visibility', 'hidden')
      }
    },
    zoomState(state) {
      // check if sourceEvent exists and if this is the same component
      // - if true, don't update the zoom (this is to prevent the infinite loop)

      if (state.zoomingID === this.chartID) {
        return
      }
      // update zoom of the chart
      if (this.isVisible) {
        this.updateZoom(state.selection)
      }

      const transform = this.normalizedSelection2Transform(state.selection)

      // then update the zoom transform internally with the new transform settings of the other components
      this.$chart.node().__zoom = transform
    },
    width() {
      this.$chart.call(this.zoom)
      this.$nextTick(() => {
        if (this.isVisible) {
          this.updateZoom(this.zoomState.selection)
        }
      })
    },
    redrawTrigger() {
      if (this.isVisible) {
        if (this.activeImageName) {
          this.drawActivePoint(
            this.activeImageName,
            this.xScaleZoomed,
            this.yScale
          )
        }
        this.redrawPoints(this.xScaleZoomed)
      }
    },
    isVisible() {
      if (this.isVisible) {
        if (this.activeImageName) {
          this.drawActivePoint(
            this.activeImageName,
            this.xScaleZoomed,
            this.yScale
          )
        }
        this.redrawPoints(this.xScaleZoomed)
      }
    }
  },
  methods: {
    handleResize({ width }) {
      this.width = width
    },
    redrawPoints(x) {
      // Clear canvas and draw chart and gradient again.
      // x: Scale of the x coordinate.

      // Clear whole canvas.
      this.context.clearRect(0, 0, this.width, this.height)
      this.drawSnowHeightFloatingAverage(this.context, x, this.yScale)
      this.drawChartPoints(this.context, x, this.yScale)
      // Clear left and right sides of charts.
      this.context.clearRect(0, 0, this.margin.left, this.height)
      this.context.clearRect(
        this.width - this.margin.right,
        0,
        this.margin.right,
        this.height
      )
    },
    zoomed(event) {
      // Generate normalized selection from transform object.
      const normSel = this.transform2NormalizedSelection(event.transform)
      // Update zoom of this component.
      if (this.isVisible) {
        this.updateZoom(normSel)
      }

      // Create state object describing zoom and save it to the Vuex store.
      const state = { selection: normSel, zoomingID: this.chartID }
      this.$store.dispatch('setZoomState', state)
    },
    updateZoom(normalizedSelection) {
      // Update zoom on this chart, using the transform.
      // Apply transformation to the x axis and the chart points.
      const transform = this.normalizedSelection2Transform(normalizedSelection)
      const xz = transform.rescaleX(this.xScale)

      // Redraw x axis.
      this.$xAxis.call(this.xAxis, xz)

      // Redraw active point.
      if (this.activeImageName) {
        this.drawActivePoint(this.activeImageName, xz, this.yScale)
      }

      // Redraw gradient background.
      this.$gradient.attr('transform', transform)

      // Redraw canvas with points.
      this.redrawPoints(xz)
    },
    transform2NormalizedSelection(transform) {
      const x = transform.x
      const k = transform.k
      if (k === 1) {
        return [0, 1]
      }
      const s = (x + (k - 1) * this.rangeX[0]) / (-k * this.spanX)
      const e = s + 1 / k
      return [s, e]
    },
    normalizedSelection2Transform(normalizedSelection) {
      const start = normalizedSelection[0]
      const end = normalizedSelection[1]
      const k = 1 / (end - start)
      const x = -(start * k * this.spanX + (k - 1) * this.rangeX[0])
      return d3.zoomIdentity.translate(x, 0).scale(k)
    },

    mousemoved(event) {
      const mouseX = event.offsetX
      const mouseY = event.offsetY
      const imageName = this.quadtree.find(mouseX, mouseY)
      this.drawHoveredPoint(imageName, this.xScaleZoomed, this.yScale)
    },
    clicked(event) {
      const mouseX = event.offsetX
      const mouseY = event.offsetY
      const imageName = this.quadtree.find(mouseX, mouseY)
      if (imageName != null && imageName != this.activeImageName) {
        this.activeImageName = imageName
      }
    },

    xAxis(g, x) {
      g.attr(
        'transform',
        `translate(0,${this.height - this.margin.bottom})`
      ).call(
        d3
          .axisBottom(x)
          .ticks(10)
          .tickSizeOuter(0)
      )
    },
    yAxis(g, y) {
      g.attr('transform', `translate(${this.margin.left},0)`)
        .call(
          d3
            .axisLeft(y)
            .ticks(5)
            .tickSizeOuter(0)
        )
        .call(g =>
          g
            .select('.tick:last-of-type text')
            .clone()
            .attr('x', 0)
            .attr('y', -7)
            .attr('text-anchor', 'start')
            .attr('font-weight', 'bold')
            .text('cm')
        )
    },

    drawChartPoints(context, x, y) {
      // context: Canvas context, used for drawing into canvas.
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      for (const imageName in this.chartData) {
        if (Object.hasOwnProperty.call(this.chartData, imageName)) {
          let opacity = 1
          if (
            this.$store.getters.isFilteredOut(
              this.chartData[imageName],
              this.stickID
            )
          ) {
            opacity = 0.2
          }
          const timestamp = this.chartData[imageName].dateTime
          const snowHeight = this.chartData[imageName].sticks[this.stickID]
            .snowHeight
          const type = this.stickType(imageName)
          if (type.type === 'cross') {
            this.drawCross(
              context,
              timestamp,
              snowHeight,
              x,
              y,
              type.color,
              opacity
            )
          }
          if (type.type === 'point') {
            this.drawPoint(
              context,
              timestamp,
              snowHeight,
              x,
              y,
              type.color,
              opacity
            )
          }
        }
      }
    },

    stickType(imageName) {
      // Select type of a datapoint, based on the attributes.

      const image = this.chartData[imageName]
      const stick = image.sticks[this.stickID]
      const result = { type: '', color: '', opacity: 1 }

      // Stick discarded
      if (image.discardedByUser || stick.discardedByUser) {
        result.type = 'cross'
        result.color = '#888888'
        result.opacity = 1
        return result
      }
      // Stick not found but image approved.
      if (!stick.visible && image.approvedByUser) {
        result.type = 'cross'
        result.color = '#88a509'
        result.opacity = 1
        return result
      }
      // Stick manually edited
      if (stick.correctedByUser || image.approvedByUser) {
        result.type = 'point'
        result.color = '#88a509'
        result.opacity = 1
        return result
      }
      // No snow detected
      if (image.weatherConditions === 0) {
        result.type = 'point'
        result.color = '#6696c3'
        result.opacity = 1
        return result
      }
      // Stick not found
      if (!stick.visible) {
        result.type = 'point'
        result.color = '#d4aca9'
        result.opacity = 1
        return result
      }
      // Stick normal/skipped
      result.type = 'point'
      result.color = '#555555'
      result.opacity = 1
      return result
      // Outlier
      // Custom user tag
    },

    drawPoint(context, timestamp, snowHeight, x, y, color, opacity) {
      // context: Canvas context, used for drawing into canvas.
      // timestamp: (x axis) string, containing standard time format.
      // snowHeight: (y axis) number.
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      // color: Color of the point and the line.
      const px = Math.floor(x(new Date(timestamp)))
      // If the point is out of bounds, don't draw it.
      if (px >= 0 && px <= this.width) {
        const py = Math.floor(y(snowHeight))

        context.beginPath()
        context.globalAlpha = opacity || 1
        context.fillStyle = color || '#555555'
        context.strokeStyle = color || '#555555'
        context.lineWidth = this.datapointLineWidth
        context.arc(px, py, this.datapointRadius, 0, 2 * Math.PI, true)
        context.fill()
      }
    },
    drawCross(context, timestamp, snowHeight, x, y, color, opacity) {
      // context: Canvas context, used for drawing into canvas.
      // timestamp: (x axis) string, containing standard time format.
      // snowHeight: (y axis) number.
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      // color: Color of the point and the line.
      const px = Math.floor(x(new Date(timestamp)))
      // If the point is out of bounds, don't draw it.
      if (px >= 0 && px <= this.width) {
        const py = Math.floor(y(snowHeight))

        context.beginPath()
        context.strokeStyle = color || '#555555'
        context.globalAlpha = opacity || 1
        context.lineWidth = this.datapointLineWidth * 2
        context.lineCap = 'round'
        context.moveTo(px - this.datapointRadius, py - this.datapointRadius)
        context.lineTo(px + this.datapointRadius, py + this.datapointRadius)
        context.moveTo(px - this.datapointRadius, py + this.datapointRadius)
        context.lineTo(px + this.datapointRadius, py - this.datapointRadius)
        context.stroke()
      }
    },

    drawSnowHeightFloatingAverage(context, x, y) {
      // context: Canvas context, used for drawing into canvas.
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      let lastTimestamp = null
      let lastSnowHeightAverage = null
      for (const image in this.chartData) {
        if (Object.hasOwnProperty.call(this.chartData, image)) {
          const timestamp = this.chartData[image].dateTime
          const snowHeightAverage = this.chartData[image].sticks[this.stickID]
            .snowHeightAverage
          if (lastTimestamp != null) {
            this.drawLine(
              context,
              lastTimestamp,
              lastSnowHeightAverage,
              timestamp,
              snowHeightAverage,
              x,
              y,
              '#5d8283'
            )
          }
          lastTimestamp = timestamp
          lastSnowHeightAverage = snowHeightAverage
        }
      }
    },

    drawLine(
      context,
      lastTimestamp,
      lastSnowHeightAverage,
      timestamp,
      snowHeightAverage,
      x,
      y,
      color
    ) {
      // context: Canvas context, used for drawing into canvas.
      // lastTimestamp, timestamp: (x axis) string, containing standard time format.
      // lastSnowHeightAverage, snowHeightAverage: (y axis) number.
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      // color: Color of the point and the line.
      const lastPx = Math.floor(x(new Date(lastTimestamp)))
      const px = Math.floor(x(new Date(timestamp)))
      // If both points are out of bounds, don't draw the line.
      if (
        (px >= 0 && px <= this.width) ||
        (lastPx >= 0 && lastPx <= this.width)
      ) {
        if (lastSnowHeightAverage === -1 || snowHeightAverage === -1) {
          return
        }
        const lastPy = Math.floor(y(lastSnowHeightAverage))
        const py = Math.floor(y(snowHeightAverage))
        context.beginPath()
        context.strokeStyle = color || '#555555'
        context.globalAlpha = 1
        context.lineWidth = 3
        context.moveTo(lastPx, lastPy)
        context.lineTo(px, py)
        context.stroke()
      }
    },

    drawHoveredPoint(imageName, x, y) {
      // point: Object, containing timestamp (x axis) and snowHeight (y axis).
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      const px = x(new Date(this.chartData[imageName].dateTime))
      const py = y(this.chartData[imageName].sticks[this.stickID].snowHeight)

      this.$hoveredPoint.attr('visibility', 'visible')

      this.$hoveredPoint
        .select('circle')
        .attr('cx', px)
        .attr('cy', py)

      this.$hoveredPoint
        .select('line')
        .attr('x1', px)
        .attr('y1', Math.min(py + this.datapointRadius, this.rangeY[0]))
        .attr('x2', px)
        .attr('y2', this.rangeY[0])

      this.$hoveredPoint
        .select('text')
        .attr('x', px)
        .attr('y', py - this.datapointRadius - 3)
        .text(this.chartData[imageName].snowHeight)
    },
    drawActivePoint(imageName, x, y) {
      // point: Object, containing timestamp (x axis) and snowHeight (y axis).
      // x: D3 scale for x axis.
      // y: D3 scale for y axis.
      const px = x(new Date(this.chartData[imageName].dateTime))
      const py = y(this.chartData[imageName].sticks[this.stickID].snowHeight)

      if (px < this.rangeX[0] || px > this.rangeX[1]) {
        this.$activePoint.attr('visibility', 'hidden')
        return
      }
      this.$activePoint.attr('visibility', 'visible')

      this.$activePoint
        .select('circle')
        .attr('cx', px)
        .attr('cy', py)

      this.$activePoint
        .select('line')
        .attr('x1', px)
        .attr('y1', Math.min(py + this.datapointRadius, this.rangeY[0]))
        .attr('x2', px)
        .attr('y2', this.rangeY[0])
    }
  },
  mounted() {
    this.$chart
      .call(this.zoom)
      .on('mousemove', this.mousemoved)
      .on('mouseleave', () => this.$hoveredPoint.attr('visibility', 'hidden'))
      .on('click', this.clicked)
      .on('mousedown', () => this.$hoveredPoint.attr('visibility', 'hidden'))
      .on('mouseup', () => this.$hoveredPoint.attr('visibility', 'visible'))
      .on('wheel', event => {
        this.$hoveredPoint.attr('visibility', 'hidden')
        // Prevent scrolling the whole page when using mouse wheel on chart.
        if (
          event.deltaY > 0 &&
          this.zoomState.selection[0] === 0 &&
          this.zoomState.selection[1] === 1
        ) {
          event.preventDefault()
        }
      })
    this.$xAxis.call(this.xAxis, this.xScale)
    this.$yAxis.call(this.yAxis, this.yScale)

    this.width = this.$chart.node().clientWidth
  }
}
</script>

<style lang="scss" scoped>
.chart {
  position: relative;
  background: var(--color-label-background);
  border-radius: 5px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
}

.chart-axes {
  position: absolute;
  left: 0px;
  top: 0px;
  z-index: 2;
  width: 100%;
}

.chart-gradient {
  position: absolute;
  // left: var(--chart-margin-left);
  left: 0;
  top: var(--chart-margin-top);
  z-index: 0;
}

.chart-gradient rect {
  fill: var(--color-label-background);
}

.chart-points {
  position: absolute;
  left: 0px;
  top: 0px;
  z-index: 1;
}

circle.hoveredPoint {
  fill: none;
  stroke: var(--color-accent-2);
  stroke-width: 3px;
}
line.hoveredPoint {
  stroke-width: 3px;
  stroke: var(--color-accent-2);
}
text.hoveredPoint {
  text-anchor: middle;
  font: 10px sans-serif;
}

circle.activePoint {
  fill: none;
  stroke: var(--color-accent-3);
  stroke-width: 3px;
}
line.activePoint {
  stroke-width: 3px;
  stroke: var(--color-accent-3);
}
</style>
