<!--
/**
 * @fileoverview GanttElastic component
 * @license MIT
 * @author Rafal Pospiech <neuronet.io@gmail.com>
 * @package GanttElastic
 */
-->
<template>
  <div class="gantt-elastic" style="width:100%">
    <slot name="header" />
    <main-view ref="mainView" />
    <slot name="footer" />
  </div>
</template>

<script>
import VueInstance from 'vue'
import dayjs from 'dayjs'
import MainView from './components/MainView.vue'
import getStyle from './style.js'
import ResizeObserver from 'resize-observer-polyfill'
import Vue from 'vue'

const ctx = document.createElement('canvas').getContext('2d')
let VueInst = VueInstance
function initVue() {
  if (typeof Vue !== 'undefined' && typeof VueInst === 'undefined') {
    VueInst = Vue
  }
}
initVue()

// eslint-disable-next-line no-unused-vars
const hourWidthCache = null

/**
 * Helper function to fill out empty options in user settings
 *
 * @param {object} userOptions - initial user options that will merge with those below
 * @returns {object} merged options
 */
function getOptions(userOptions) {
  // eslint-disable-next-line no-unused-vars
  let localeName = 'en'
  if (
    typeof userOptions.locale !== 'undefined' &&
    typeof userOptions.locale.name !== 'undefined'
  ) {
    localeName = userOptions.locale.name
  }
  return {
    slots: {
      header: {}
    },
    taskMapping: {
      id: 'id',
      start: 'start',
      label: 'label',
      duration: 'duration',
      progress: 'progress',
      type: 'type',
      style: 'style',
      collapsed: 'collapsed'
    },
    width: 0,
    height: 0,
    clientWidth: 0,
    outerHeight: 0,
    rowsHeight: 0,
    allVisibleTasksHeight: 0,
    scroll: {
      scrolling: false,
      dragXMoveMultiplier: 3,
      dragYMoveMultiplier: 2,
      top: 0,
      taskList: {
        left: 0,
        right: 0,
        top: 0,
        bottom: 0
      },
      chart: {
        left: 0,
        right: 0,
        percent: 0,
        timePercent: 0,
        top: 0,
        bottom: 0,
        time: 0,
        timeCenter: 0,
        dateTime: {
          left: '',
          right: ''
        }
      }
    },
    scope: {
      before: 1,
      after: 1
    },
    times: {
      timeScale: 60 * 1000,
      timeZoom: 17,
      timePerPixel: 0,
      firstTime: null,
      lastTime: null,
      firstTaskTime: 0,
      lastTaskTime: 0,
      totalViewDurationMs: 0,
      totalViewDurationPx: 0,
      stepDuration: 'day',
      steps: []
    },
    row: {
      height: 24
    },
    maxRows: 20,
    maxHeight: 0,
    chart: {
      grid: {
        horizontal: {
          gap: 6
        }
      },
      progress: {
        width: 20,
        height: 6,
        pattern: true,
        bar: false
      },
      text: {
        offset: 4,
        xPadding: 10,
        display: true
      },
      expander: {
        type: 'chart',
        display: false,
        displayIfTaskListHidden: true,
        offset: 4,
        size: 18
      }
    },
    taskList: {
      display: true,
      resizeAfterThreshold: true,
      widthThreshold: 75,
      columns: [
        {
          id: 0,
          label: 'ID',
          value: 'id',
          width: 40
        }
      ],
      percent: 100,
      width: 0,
      finalWidth: 0,
      widthFromPercentage: 0,
      minWidth: 18,
      expander: {
        type: 'task-list',
        size: 16,
        columnWidth: 24,
        padding: 16,
        margin: 10,
        straight: false
      }
    },
    calendar: {
      workingDays: [1, 2, 3, 4, 5],
      gap: 6,
      height: 0,
      strokeWidth: 1,
      hour: {
        height: 20,
        display: true,
        widths: [],
        maxWidths: { short: 0, medium: 0, long: 0 },
        formatted: {
          long: [],
          medium: [],
          short: []
        },
        format: {
          long(date) {
            return date.format('HH:mm')
          },
          medium(date) {
            return date.format('HH:mm')
          },
          short(date) {
            return date.format('HH')
          }
        }
      },
      day: {
        height: 20,
        display: true,
        widths: [],
        maxWidths: { short: 0, medium: 0, long: 0 },
        format: {
          long(date) {
            return date.format('DD dddd')
          },
          medium(date) {
            return date.format('DD ddd')
          },
          short(date) {
            return date.format('DD')
          }
        }
      },
      month: {
        height: 20,
        display: true,
        widths: [],
        maxWidths: { short: 0, medium: 0, long: 0 },
        format: {
          short(date) {
            return date.format('MM')
          },
          medium(date) {
            return date.format("MMM 'YY")
          },
          long(date) {
            return date.format('MMMM YYYY')
          }
        }
      }
    },
    locale: {
      name: 'en',
      weekdays: 'Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday'.split(
        '_'
      ),
      weekdaysShort: 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_'),
      weekdaysMin: 'Su_Mo_Tu_We_Th_Fr_Sa'.split('_'),
      months: 'January_February_March_April_May_June_July_August_September_October_November_December'.split(
        '_'
      ),
      monthsShort: 'Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec'.split('_'),
      weekStart: 1,
      relativeTime: {
        future: 'in %s',
        past: '%s ago',
        s: 'a few seconds',
        m: 'a minute',
        mm: '%d minutes',
        h: 'an hour',
        hh: '%d hours',
        d: 'a day',
        dd: '%d days',
        M: 'a month',
        MM: '%d months',
        y: 'a year',
        yy: '%d years'
      },
      formats: {
        LT: 'HH:mm',
        LTS: 'HH:mm:ss',
        L: 'DD/MM/YYYY',
        LL: 'D MMMM YYYY',
        LLL: 'D MMMM YYYY HH:mm',
        LLLL: 'dddd, D MMMM YYYY HH:mm'
      },
      ordinal: n => {
        const s = ['th', 'st', 'nd', 'rd']
        const v = n % 100
        return `[${n}${s[(v - 20) % 10] || s[v] || s[0]}]`
      }
    }
  }
}

/**
 * Prepare style
 *
 * @returns {object}
 */
function prepareStyle(userStyle) {
  let fontSize = '12px'
  let fontFamily = window
    .getComputedStyle(document.body)
    .getPropertyValue('font-family')
    .toString()
  if (typeof userStyle !== 'undefined') {
    if (typeof userStyle.fontSize !== 'undefined') {
      fontSize = userStyle.fontSize
    }
    if (typeof userStyle.fontFamily !== 'undefined') {
      fontFamily = userStyle.fontFamily
    }
  }
  return getStyle(fontSize, fontFamily)
}

/**
 * Helper function to determine if specified variable is an object
 *
 * @param {any} item
 *
 * @returns {boolean}
 */
function isObject(item) {
  return (
    item &&
    typeof item === 'object' &&
    !Array.isArray(item) &&
    !(item instanceof HTMLElement) &&
    !(item instanceof CanvasRenderingContext2D) &&
    typeof item !== 'function'
  )
}

/**
 * Helper function which will merge objects recursively - creating brand new one - like clone
 *
 * @param {object} target
 * @params {object} sources
 *
 * @returns {object}
 */
export function mergeDeep(target, ...sources) {
  if (!sources.length) {
    return target
  }
  const source = sources.shift()
  if (isObject(target) && isObject(source)) {
    for (const key in source) {
      if (isObject(source[key])) {
        if (typeof target[key] === 'undefined') {
          target[key] = {}
        }
        target[key] = mergeDeep(target[key], source[key])
      } else if (Array.isArray(source[key])) {
        target[key] = []
        for (const item of source[key]) {
          if (isObject(item)) {
            target[key].push(mergeDeep({}, item))
            continue
          }
          target[key].push(item)
        }
      } else {
        target[key] = source[key]
      }
    }
  }
  return mergeDeep(target, ...sources)
}

/**
 * Detect if object or array is observable
 *
 * @param {object|array} obj
 *
 * @returns {boolean}
 */
// eslint-disable-next-line no-unused-vars
function isObservable(obj) {
  return typeof obj === 'object' && obj.hasOwnProperty('__ob__')
}

/**
 * Same as above but with reactivity in mind
 *
 * @param {object} target
 * @params {object} sources
 *
 * @returns {object}
 */
export function mergeDeepReactive(component, target, ...sources) {
  if (!sources.length) {
    return target
  }
  const source = sources.shift()
  if (isObject(target) && isObject(source)) {
    for (const key in source) {
      if (isObject(source[key])) {
        if (typeof target[key] === 'undefined') {
          component.$set(target, key, {})
        }
        mergeDeepReactive(component, target[key], source[key])
      } else if (Array.isArray(source[key])) {
        component.$set(target, key, source[key])
      } else if (typeof source[key] === 'function') {
        if (source[key].toString().indexOf('[native code]') === -1) {
          target[key] = source[key]
        }
      } else {
        component.$set(target, key, source[key])
      }
    }
  }
  return mergeDeepReactive(component, target, ...sources)
}
/**
 * Check if objects or arrays are equal by comparing nested values
 *
 * @param {object|array} left
 * @param {object|array} right
 *
 * @returns {boolean}
 */
export function notEqualDeep(left, right, cache = [], path = '') {
  if (typeof right !== typeof left) {
    return { left, right, what: path + '.typeof' }
  } else if (Array.isArray(left) && !Array.isArray(right)) {
    return { left, right, what: path + '.isArray' }
  } else if (Array.isArray(right) && !Array.isArray(left)) {
    return { left, right, what: path + '.isArray' }
  } else if (Array.isArray(left) && Array.isArray(right)) {
    if (left.length !== right.length) {
      return { left, right, what: path + '.length' }
    }
    let what
    for (let index = 0, len = left.length; index < len; index++) {
      if (
        (what = notEqualDeep(
          left[index],
          right[index],
          cache,
          path + '.' + index
        ))
      ) {
        return what
      }
    }
  } else if (isObject(left) && !isObject(right)) {
    return { left, right, what: path + '.isObject' }
  } else if (isObject(right) && !isObject(left)) {
    return { left, right, what: path + '.isObject' }
  } else if (isObject(left) && isObject(right)) {
    for (const key in left) {
      if (!left.hasOwnProperty(key) || !left.propertyIsEnumerable(key)) {
        continue
      }
      if (!right.hasOwnProperty(key)) {
        return { left, right, what: path + '.' + key }
      }
      let what
      if (
        (what = notEqualDeep(left[key], right[key], cache, path + '.' + key))
      ) {
        return what
      }
    }
  } else if (left !== right) {
    return { left, right, what: path + '. !==' }
  }
  return false
}

/**
 * GanttElastic
 * Main vue component
 */
const GanttElastic = {
  name: 'GanttElastic',
  components: {
    MainView
  },
  props: ['tasks', 'options', 'dynamicStyle'],
  provide() {
    const provider = {}
    const self = this
    Object.defineProperty(provider, 'root', {
      enumerable: true,
      get: () => self
    })
    return provider
  },
  data() {
    return {
      state: {
        tasks: [],
        options: {
          scrollBarHeight: 0,
          allVisibleTasksHeight: 0,
          outerHeight: 0,
          scroll: {
            left: 0,
            top: 0
          }
        },
        dynamicStyle: {},
        refs: {},
        tasksById: {},
        taskTree: {},
        ctx,
        emitTasksChanges: true, // some operations may pause emitting changes to parent component
        emitOptionsChanges: true, // some operations may pause emitting changes to parent component
        resizeObserver: null,
        unwatchTasks: null,
        unwatchOptions: null,
        unwatchStyle: null,
        unwatchOutputTasks: null,
        unwatchOutputOptions: null,
        unwatchOutputStyle: null
      }
    }
  },
  methods: {
    mergeDeep,
    mergeDeepReactive,

    /**
     * Calculate height of scrollbar in current browser
     *
     * @returns {number}
     */
    getScrollBarHeight() {
      const outer = document.createElement('div')
      outer.style.visibility = 'hidden'
      outer.style.height = '100px'
      outer.style.msOverflowStyle = 'scrollbar'
      document.body.appendChild(outer)
      var noScroll = outer.offsetHeight
      outer.style.overflow = 'scroll'
      var inner = document.createElement('div')
      inner.style.height = '100%'
      outer.appendChild(inner)
      var withScroll = inner.offsetHeight
      outer.parentNode.removeChild(outer)
      const height = noScroll - withScroll
      this.style['chart-scroll-container--vertical'][
        'margin-left'
      ] = `-${height}px`
      return (this.state.options.scrollBarHeight = height)
    },

    /**
     * Fill out empty task properties and make it reactive
     *
     * @param {array} tasks
     */
    fillTasks(tasks) {
      for (const task of tasks) {
        if (typeof task.x === 'undefined') {
          task.x = 0
        }
        if (typeof task.y === 'undefined') {
          task.y = 0
        }
        if (typeof task.width === 'undefined') {
          task.width = 0
        }
        if (typeof task.height === 'undefined') {
          task.height = 0
        }
        if (typeof task.mouseOver === 'undefined') {
          task.mouseOver = false
        }
        if (typeof task.collapsed === 'undefined') {
          task.collapsed = false
        }
        if (typeof task.dependentOn === 'undefined') {
          task.dependentOn = []
        }
        if (typeof task.parentId === 'undefined') {
          task.parentId = null
        }
        if (typeof task.style === 'undefined') {
          task.style = {}
        }
        if (typeof task.children === 'undefined') {
          task.children = []
        }
        if (typeof task.allChildren === 'undefined') {
          task.allChildren = []
        }
        if (typeof task.parents === 'undefined') {
          task.parents = []
        }
        if (typeof task.parent === 'undefined') {
          task.parent = null
        }
        if (typeof task.startTime === 'undefined') {
          task.startTime = dayjs(task.start).valueOf()
        }
        if (typeof task.endTime === 'undefined' && task.hasOwnProperty('end')) {
          task.endTime = dayjs(task.end).valueOf()
        } else if (
          typeof task.endTime === 'undefined' &&
          task.hasOwnProperty('duration')
        ) {
          task.endTime = task.startTime + task.duration
        }
        if (
          typeof task.duration === 'undefined' &&
          task.hasOwnProperty('endTime')
        ) {
          task.duration = task.endTime - task.startTime
        }
      }
      return tasks
    },

    /**
     * Map tasks
     *
     * @param {Array} tasks
     * @param {Object} options
     */
    mapTasks(tasks, options) {
      for (const [index, task] of tasks.entries()) {
        tasks[index] = {
          ...task,
          id: task[options.taskMapping.id],
          start: task[options.taskMapping.start],
          label: task[options.taskMapping.label],
          duration: task[options.taskMapping.duration],
          progress: task[options.taskMapping.progress],
          type: task[options.taskMapping.type],
          style: task[options.taskMapping.style],
          collapsed: task[options.taskMapping.collapsed]
        }
      }
      return tasks
    },

    /**
     * Initialize component
     */
    initialize(itsUpdate = '') {
      const options = mergeDeep(
        {},
        this.state.options,
        getOptions(this.options),
        this.options
      )
      let tasks = this.mapTasks(this.tasks, options)
      if (Object.keys(this.state.dynamicStyle).length === 0) {
        this.initializeStyle()
      }
      dayjs.locale(options.locale, null, true)
      dayjs.locale(options.locale.name)
      if (typeof options.taskList === 'undefined') {
        options.taskList = {}
      }
      options.taskList.columns = options.taskList.columns.map(
        (column, index) => {
          column.thresholdPercent = 100
          column.widthFromPercentage = 0
          column.finalWidth = 0
          if (typeof column.height === 'undefined') {
            column.height = 0
          }
          if (typeof column.style === 'undefined') {
            column.style = {}
          }
          column._id = `${index}-${column.label}`
          return column
        }
      )
      this.state.options = options
      tasks = this.fillTasks(tasks)
      this.state.tasksById = this.resetTaskTree(tasks)
      this.state.taskTree = this.makeTaskTree(this.state.rootTask, tasks)
      this.state.tasks = this.state.taskTree.allChildren.map(childId =>
        this.getTask(childId)
      )
      this.calculateTaskListColumnsDimensions()
      this.state.options.scrollBarHeight = this.getScrollBarHeight()
      this.state.options.outerHeight =
        this.state.options.height + this.state.options.scrollBarHeight
      this.globalOnResize()
    },

    /**
     * Initialize style
     */
    initializeStyle() {
      this.state.dynamicStyle = mergeDeep(
        {},
        prepareStyle(this.dynamicStyle),
        this.dynamicStyle
      )
    },

    /**
     * Get calendar rows outer height
     *
     * @returns {int}
     */
    getCalendarHeight() {
      return (
        this.state.options.calendar.height +
        this.state.options.calendar.strokeWidth
      )
    },

    /**
     * Get maximal level of nested task children
     *
     * @returns {int}
     */
    getMaximalLevel() {
      let maximalLevel = 0
      this.state.tasks.forEach(task => {
        if (task.parents.length > maximalLevel) {
          maximalLevel = task.parents.length
        }
      })
      return maximalLevel - 1
    },

    /**
     * Get maximal expander width - to calculate straight task list text
     *
     * @returns {int}
     */
    getMaximalExpanderWidth() {
      return (
        this.getMaximalLevel() * this.state.options.taskList.expander.padding +
        this.state.options.taskList.expander.margin
      )
    },

    /**
     * Synchronize scrollTop property when row height is changed
     */
    syncScrollTop() {
      if (
        this.state.refs.taskListItems &&
        this.state.refs.chartGraph.scrollTop !==
          this.state.refs.taskListItems.scrollTop
      ) {
        this.state.options.scroll.top = this.state.refs.taskListItems.scrollTop = this.state.refs.chartScrollContainerVertical.scrollTop = this.state.refs.chartGraph.scrollTop
      }
    },

    /**
     * Calculate task list columns dimensions
     */
    calculateTaskListColumnsDimensions() {
      let final = 0
      let percentage = 0
      for (const column of this.state.options.taskList.columns) {
        if (column.expander) {
          column.widthFromPercentage =
            ((this.getMaximalExpanderWidth() + column.width) / 100) *
            this.state.options.taskList.percent
        } else {
          column.widthFromPercentage =
            (column.width / 100) * this.state.options.taskList.percent
        }
        percentage += column.widthFromPercentage
        column.finalWidth =
          (column.thresholdPercent * column.widthFromPercentage) / 100
        final += column.finalWidth
        column.height =
          this.getTaskHeight() -
          this.style['grid-line-horizontal']['stroke-width']
      }
      this.state.options.taskList.widthFromPercentage = percentage
      this.state.options.taskList.finalWidth = final
    },

    /**
     * Reset task tree - which is used to create tree like structure inside task list
     */
    resetTaskTree(tasks) {
      this.$set(this.state, 'rootTask', {
        id: null,
        label: 'root',
        children: [],
        allChildren: [],
        parents: [],
        parent: null,
        __root: true
      })
      const tasksById = {}
      for (let i = 0, len = tasks.length; i < len; i++) {
        const current = tasks[i]
        current.children = []
        current.allChildren = []
        current.parent = null
        current.parents = []
        tasksById[current.id] = current
      }
      return tasksById
    },

    /**
     * Make task tree, after reset - look above
     *
     * @param {object} task
     * @returns {object} tasks with children and parents
     */
    makeTaskTree(task, tasks) {
      for (let i = 0, len = tasks.length; i < len; i++) {
        let current = tasks[i]
        if (current.parentId === task.id) {
          if (task.parents.length) {
            task.parents.forEach(parent => current.parents.push(parent))
          }
          if (!task.propertyIsEnumerable('__root')) {
            current.parents.push(task.id)
            current.parent = task.id
          } else {
            current.parents = []
            current.parent = null
          }
          current = this.makeTaskTree(current, tasks)
          task.allChildren.push(current.id)
          task.children.push(current.id)
          current.allChildren.forEach(childId =>
            task.allChildren.push(childId)
          )
        }
      }
      return task
    },

    /**
     * Get task by id
     *
     * @param {any} taskId
     * @returns {object|null} task
     */
    getTask(taskId) {
      if (typeof this.state.tasksById[taskId] !== 'undefined') {
        return this.state.tasksById[taskId]
      }
      return null
    },

    /**
     * Get children tasks for specified taskId
     *
     * @param {any} taskId
     * @returns {array} children
     */
    getChildren(taskId) {
      return this.state.tasks.filter(task => task.parent === taskId)
    },

    /**
     * Is task visible
     *
     * @param {Number|String|Task} task
     */
    isTaskVisible(task) {
      if (typeof task === 'number' || typeof task === 'string') {
        task = this.getTask(task)
      }
      for (let i = 0, len = task.parents.length; i < len; i++) {
        if (this.getTask(task.parents[i]).collapsed) {
          return false
        }
      }
      return true
    },

    /**
     * Get svg
     *
     * @returns {string} html svg image of gantt
     */
    getSVG() {
      return this.state.options.mainView.outerHTML
    },

    /**
     * Get image
     *
     * @param {string} type image format
     * @returns {Promise} when resolved returns base64 image string of gantt
     */
    getImage(type = 'image/png') {
      return new Promise(resolve => {
        const img = new Image()
        img.onload = () => {
          const canvas = document.createElement('canvas')
          canvas.width = this.state.options.mainView.clientWidth
          canvas.height = this.state.options.rowsHeight
          canvas.getContext('2d').drawImage(img, 0, 0)
          resolve(canvas.toDataURL(type))
        }
        img.src = 'data:image/svg+xml,' + encodeURIComponent(this.getSVG())
      })
    },

    /**
     * Get gantt total height
     *
     * @returns {number}
     */
    getHeight(visibleTasks, outer = false) {
      let height =
        visibleTasks.length *
          (this.state.options.row.height +
            this.state.options.chart.grid.horizontal.gap * 2) +
        this.state.options.calendar.height +
        this.state.options.calendar.strokeWidth +
        this.state.options.calendar.gap
      if (outer) {
        height += this.state.options.scrollBarHeight
      }
      return height
    },

    /**
     * Get one task height
     *
     * @returns {number}
     */
    getTaskHeight(withStroke = false) {
      if (withStroke) {
        return (
          this.state.options.row.height +
          this.state.options.chart.grid.horizontal.gap * 2 +
          this.style['grid-line-horizontal']['stroke-width']
        )
      }
      return (
        this.state.options.row.height +
        this.state.options.chart.grid.horizontal.gap * 2
      )
    },

    /**
     * Get specified tasks height
     *
     * @returns {number}
     */
    getTasksHeight(visibleTasks) {
      return visibleTasks.length * this.getTaskHeight()
    },

    /**
     * Convert time (in milliseconds) to pixel offset inside chart
     *
     * @param {int} ms
     * @returns {number}
     */
    timeToPixelOffsetX(ms) {
      let x = ms - this.state.options.times.firstTime
      if (x) {
        x = x / this.state.options.times.timePerPixel
      }
      return x
    },

    /**
     * Convert pixel offset inside chart to corresponding time offset in milliseconds
     *
     * @param {number} pixelOffsetX
     * @returns {int} milliseconds
     */
    pixelOffsetXToTime(pixelOffsetX) {
      const offset =
        pixelOffsetX + this.style['grid-line-vertical']['stroke-width'] / 2
      return (
        offset * this.state.options.times.timePerPixel +
        this.state.options.times.firstTime
      )
    },

    /**
     * Determine if element is inside current view port
     *
     * @param {number} x - element placement
     * @param {number} width - element width
     * @param {int} buffer - or threshold, if element is outside viewport but offset from view port is below this value return true
     * @returns {boolean}
     */
    isInsideViewPort(x, width, buffer = 5000) {
      return (
        (x + width + buffer >= this.state.options.scroll.chart.left &&
          x - buffer <= this.state.options.scroll.chart.right) ||
        (x - buffer <= this.state.options.scroll.chart.left &&
          x + width + buffer >= this.state.options.scroll.chart.right)
      )
    },

    /**
     * Chart scroll event handler
     *
     * @param {event} ev
     */
    onScrollChart(ev) {
      this._onScrollChart(
        this.state.refs.chartScrollContainerHorizontal.scrollLeft,
        this.state.refs.chartScrollContainerVertical.scrollTop
      )
    },

    /**
     * After same as above but with different arguments - normalized
     *
     * @param {number} left
     * @param {number} top
     */
    _onScrollChart(left, top) {
      if (
        this.state.options.scroll.chart.left === left &&
        this.state.options.scroll.chart.top === top
      ) {
        return
      }
      const chartContainerWidth = this.state.refs.chartContainer.clientWidth
      this.state.options.scroll.chart.left = left
      this.state.options.scroll.chart.right = left + chartContainerWidth
      this.state.options.scroll.chart.percent =
        (left / this.state.options.times.totalViewDurationPx) * 100
      this.state.options.scroll.chart.top = top
      this.state.options.scroll.chart.time = this.pixelOffsetXToTime(left)
      this.state.options.scroll.chart.timeCenter = this.pixelOffsetXToTime(
        left + chartContainerWidth / 2
      )
      this.state.options.scroll.chart.dateTime.left = dayjs(
        this.state.options.scroll.chart.time
      ).valueOf()
      this.state.options.scroll.chart.dateTime.right = dayjs(
        this.pixelOffsetXToTime(left + this.state.refs.chart.clientWidth)
      ).valueOf()
      this.scrollTo(left, top)
    },

    /**
     * Scroll current chart to specified time (in milliseconds)
     *
     * @param {int} time
     */
    scrollToTime(time) {
      let pos = this.timeToPixelOffsetX(time)
      const chartContainerWidth = this.state.refs.chartContainer.clientWidth
      pos = pos - chartContainerWidth / 2
      if (pos > this.state.options.width) {
        pos = this.state.options.width - chartContainerWidth
      }
      this.scrollTo(pos)
    },

    /**
     * Scroll chart or task list to specified pixel values
     *
     * @param {number|null} left
     * @param {number|null} top
     */
    scrollTo(left = null, top = null) {
      if (left !== null) {
        this.state.refs.chartCalendarContainer.scrollLeft = left
        this.state.refs.chartGraphContainer.scrollLeft = left
        this.state.refs.chartScrollContainerHorizontal.scrollLeft = left
        this.state.options.scroll.left = left
      }
      if (top !== null) {
        this.state.refs.chartScrollContainerVertical.scrollTop = top
        this.state.refs.chartGraph.scrollTop = top
        this.state.refs.taskListItems.scrollTop = top
        this.state.options.scroll.top = top
        this.syncScrollTop()
      }
    },

    /**
     * After some actions like time zoom change we need to recompensate scroll position
     * so as a result everything will be in same place
     */
    fixScrollPos() {
      this.scrollToTime(this.state.options.scroll.chart.timeCenter)
    },

    /**
     * Mouse wheel event handler
     */
    onWheelChart(ev) {
      if (!ev.shiftKey && ev.deltaX === 0) {
        let top = this.state.options.scroll.top + ev.deltaY
        const chartClientHeight = this.state.options.rowsHeight
        const scrollHeight =
          this.state.refs.chartGraph.scrollHeight - chartClientHeight
        if (top < 0) {
          top = 0
        } else if (top > scrollHeight) {
          top = scrollHeight
        }
        this.scrollTo(null, top)
      } else if (ev.shiftKey && ev.deltaX === 0) {
        let left = this.state.options.scroll.left + ev.deltaY
        const chartClientWidth = this.state.refs.chartScrollContainerHorizontal
          .clientWidth
        const scrollWidth =
          this.state.refs.chartScrollContainerHorizontal.scrollWidth -
          chartClientWidth
        if (left < 0) {
          left = 0
        } else if (left > scrollWidth) {
          left = scrollWidth
        }
        this.scrollTo(left)
      } else {
        let left = this.state.options.scroll.left + ev.deltaX
        const chartClientWidth = this.state.refs.chartScrollContainerHorizontal
          .clientWidth
        const scrollWidth =
          this.state.refs.chartScrollContainerHorizontal.scrollWidth -
          chartClientWidth
        if (left < 0) {
          left = 0
        } else if (left > scrollWidth) {
          left = scrollWidth
        }
        this.scrollTo(left)
      }
    },

    /**
     * Time zoom change event handler
     */
    onTimeZoomChange(timeZoom) {
      this.state.options.times.timeZoom = timeZoom
      this.recalculateTimes()
      this.calculateSteps()
      this.fixScrollPos()
    },

    /**
     * Row height change event handler
     */
    onRowHeightChange(height) {
      this.state.options.row.height = height
      this.calculateTaskListColumnsDimensions()
      this.syncScrollTop()
    },

    /**
     * Scope change event handler
     */
    onScopeChange(value) {
      this.state.options.scope.before = value
      this.state.options.scope.after = value
      this.initTimes()
      this.calculateSteps()
      this.computeCalendarWidths()
      this.fixScrollPos()
    },

    /**
     * Task list width change event handler
     */
    onTaskListWidthChange(value) {
      this.state.options.taskList.percent = value
      this.calculateTaskListColumnsDimensions()
      this.fixScrollPos()
    },

    /**
     * Task list column width change event handler
     */
    onTaskListColumnWidthChange() {
      this.calculateTaskListColumnsDimensions()
      this.fixScrollPos()
    },

    /**
     * Listen to specified event names
     */
    initializeEvents() {
      this.$on('chart-scroll-horizontal', this.onScrollChart)
      this.$on('chart-scroll-vertical', this.onScrollChart)
      this.$on('chart-wheel', this.onWheelChart)
      this.$on('times-timeZoom-change', this.onTimeZoomChange)
      this.$on('row-height-change', this.onRowHeightChange)
      this.$on('scope-change', this.onScopeChange)
      this.$on('taskList-width-change', this.onTaskListWidthChange)
      this.$on(
        'taskList-column-width-change',
        this.onTaskListColumnWidthChange
      )
    },

    /**
     * When some action was performed (scale change for example) - recalculate time variables
     */
    recalculateTimes() {
      const max = this.state.options.times.timeScale * 60
      const min = this.state.options.times.timeScale
      const steps = max / min
      const percent = this.state.options.times.timeZoom / 100
      this.state.options.times.timePerPixel =
        this.state.options.times.timeScale * steps * percent +
        Math.pow(2, this.state.options.times.timeZoom)
      this.state.options.times.totalViewDurationMs = dayjs(
        this.state.options.times.lastTime
      ).diff(this.state.options.times.firstTime, 'milliseconds')
      this.state.options.times.totalViewDurationPx =
        this.state.options.times.totalViewDurationMs /
        this.state.options.times.timePerPixel
      this.state.options.width =
        this.state.options.times.totalViewDurationPx +
        this.style['grid-line-vertical']['stroke-width']
    },

    /**
     * Initialize time variables
     */
    initTimes() {
      this.state.options.times.firstTime = dayjs(
        this.state.options.times.firstTaskTime
      )
        .locale(this.state.options.locale.name)
        .startOf('day')
        .subtract(this.state.options.scope.before, 'days')
        .startOf('day')
        .valueOf()
      this.state.options.times.lastTime = dayjs(
        this.state.options.times.lastTaskTime
      )
        .locale(this.state.options.locale.name)
        .endOf('day')
        .add(this.state.options.scope.after, 'days')
        .endOf('day')
        .valueOf()
      this.recalculateTimes()
    },

    /**
     * Calculate steps
     * Steps are days by default
     * Each step contain information about time offset and pixel offset of this time inside gantt chart
     */
    calculateSteps() {
      const steps = []
      const lastMs = dayjs(this.state.options.times.lastTime).valueOf()
      const currentDate = dayjs(this.state.options.times.firstTime)
      steps.push({
        time: currentDate.valueOf(),
        offset: {
          ms: 0,
          px: 0
        }
      })
      for (
        let currentDate = dayjs(this.state.options.times.firstTime)
          .add(1, this.state.options.times.stepDuration)
          .startOf('day');
        currentDate.valueOf() <= lastMs;
        currentDate = currentDate
          .add(1, this.state.options.times.stepDuration)
          .startOf('day')
      ) {
        const offsetMs = currentDate.diff(
          this.state.options.times.firstTime,
          'milliseconds'
        )
        const offsetPx = offsetMs / this.state.options.times.timePerPixel
        const step = {
          time: currentDate.valueOf(),
          offset: {
            ms: offsetMs,
            px: offsetPx
          }
        }
        const previousStep = steps[steps.length - 1]
        previousStep.width = {
          ms: offsetMs - previousStep.offset.ms,
          px: offsetPx - previousStep.offset.px
        }
        steps.push(step)
      }
      const lastStep = steps[steps.length - 1]
      lastStep.width = {
        ms: this.state.options.times.totalViewDurationMs - lastStep.offset.ms,
        px: this.state.options.times.totalViewDurationPx - lastStep.offset.px
      }
      this.state.options.times.steps = steps
    },

    /**
     * Calculate calendar widths - when scale was changed for example
     */
    computeCalendarWidths() {
      this.computeDayWidths()
      this.computeHourWidths()
      this.computeMonthWidths()
    },

    /**
     * Compute width of calendar hours column widths basing on text widths
     */
    computeHourWidths() {
      const style = {
        ...this.style['calendar-row-text'],
        ...this.style['calendar-row-text--hour']
      }
      this.state.ctx.font = style['font-size'] + ' ' + style['font-family']
      const localeName = this.state.options.locale.name
      let currentDate = dayjs('2018-01-01T00:00:00').locale(localeName) // any date will be good for hours
      const maxWidths = this.state.options.calendar.hour.maxWidths
      if (maxWidths.length) {
        return
      }
      for (const formatName in this.state.options.calendar.hour.format) {
        maxWidths[formatName] = 0
      }
      for (let hour = 0; hour < 24; hour++) {
        const widths = { hour }
        for (const formatName in this.state.options.calendar.hour.format) {
          const hourFormatted = this.state.options.calendar.hour.format[
            formatName
          ](currentDate)
          this.state.options.calendar.hour.formatted[formatName].push(
            hourFormatted
          )
          widths[formatName] = this.state.ctx.measureText(hourFormatted).width
        }
        this.state.options.calendar.hour.widths.push(widths)
        for (const formatName in this.state.options.calendar.hour.format) {
          if (widths[formatName] > maxWidths[formatName]) {
            maxWidths[formatName] = widths[formatName]
          }
        }
        currentDate = currentDate.add(1, 'hour')
      }
    },

    /**
     * Compute calendar days column widths basing on text widths
     */
    computeDayWidths() {
      const style = {
        ...this.style['calendar-row-text'],
        ...this.style['calendar-row-text--day']
      }
      this.state.ctx.font = style['font-size'] + ' ' + style['font-family']
      const localeName = this.state.options.locale.name
      let currentDate = dayjs(this.state.options.times.steps[0].time).locale(
        localeName
      )
      const maxWidths = this.state.options.calendar.day.maxWidths
      this.state.options.calendar.day.widths = []
      Object.keys(this.state.options.calendar.day.format).forEach(
        formatName => {
          maxWidths[formatName] = 0
        }
      )
      for (
        let day = 0, daysLen = this.state.options.times.steps.length;
        day < daysLen;
        day++
      ) {
        const widths = {
          day
        }
        Object.keys(this.state.options.calendar.day.format).forEach(
          formatName => {
            widths[formatName] = this.state.ctx.measureText(
              this.state.options.calendar.day.format[formatName](currentDate)
            ).width
          }
        )
        this.state.options.calendar.day.widths.push(widths)
        Object.keys(this.state.options.calendar.day.format).forEach(
          formatName => {
            if (widths[formatName] > maxWidths[formatName]) {
              maxWidths[formatName] = widths[formatName]
            }
          }
        )
        currentDate = currentDate.add(1, 'day')
      }
    },

    /**
     * Months count
     *
     * @description Returns number of different months in specified time range
     *
     * @param {number} fromTime - date in ms
     * @param {number} toTime - date in ms
     *
     * @returns {number} different months count
     */
    monthsCount(fromTime, toTime) {
      if (fromTime > toTime) {
        return 0
      }
      let currentMonth = dayjs(fromTime)
      let previousMonth = currentMonth.clone()
      let monthsCount = 1
      while (currentMonth.valueOf() <= toTime) {
        currentMonth = currentMonth.add(1, 'day')
        if (previousMonth.month() !== currentMonth.month()) {
          monthsCount++
        }
        previousMonth = currentMonth.clone()
      }
      return monthsCount
    },

    /**
     * Compute month calendar columns widths basing on text widths
     */
    computeMonthWidths() {
      const style = {
        ...this.style['calendar-row-text'],
        ...this.style['calendar-row-text--month']
      }
      this.state.ctx.font = style['font-size'] + ' ' + style['font-family']
      const maxWidths = this.state.options.calendar.month.maxWidths
      this.state.options.calendar.month.widths = []
      Object.keys(this.state.options.calendar.month.format).forEach(
        formatName => {
          maxWidths[formatName] = 0
        }
      )
      const localeName = this.state.options.locale.name
      let currentDate = dayjs(this.state.options.times.firstTime).locale(
        localeName
      )
      const monthsCount = this.monthsCount(
        this.state.options.times.firstTime,
        this.state.options.times.lastTime
      )
      for (let month = 0; month < monthsCount; month++) {
        const widths = {
          month
        }
        Object.keys(this.state.options.calendar.month.format).forEach(
          formatName => {
            widths[formatName] = this.state.ctx.measureText(
              this.state.options.calendar.month.format[formatName](currentDate)
            ).width
          }
        )
        this.state.options.calendar.month.widths.push(widths)
        Object.keys(this.state.options.calendar.month.format).forEach(
          formatName => {
            if (widths[formatName] > maxWidths[formatName]) {
              maxWidths[formatName] = widths[formatName]
            }
          }
        )
        currentDate = currentDate.add(1, 'month')
      }
    },

    /**
     * Prepare time and date variables for gantt
     */
    prepareDates() {
      let firstTaskTime = Number.MAX_SAFE_INTEGER
      let lastTaskTime = 0
      for (let index = 0, len = this.state.tasks.length; index < len; index++) {
        const task = this.state.tasks[index]
        if (task.startTime < firstTaskTime) {
          firstTaskTime = task.startTime
        }
        if (task.startTime + task.duration > lastTaskTime) {
          lastTaskTime = task.startTime + task.duration
        }
      }
      this.state.options.times.firstTaskTime = firstTaskTime
      this.state.options.times.lastTaskTime = lastTaskTime
      this.state.options.times.firstTime = dayjs(firstTaskTime)
        .locale(this.state.options.locale.name)
        .startOf('day')
        .subtract(this.state.options.scope.before, 'days')
        .startOf('day')
        .valueOf()
      this.state.options.times.lastTime = dayjs(lastTaskTime)
        .locale(this.state.options.locale.name)
        .endOf('day')
        .add(this.state.options.scope.after, 'days')
        .endOf('day')
        .valueOf()
    },

    /**
     * Setup and calculate everything
     */
    setup(itsUpdate = '') {
      this.initialize(itsUpdate)
      this.prepareDates()
      this.initTimes()
      this.calculateSteps()
      this.computeCalendarWidths()
      this.state.options.taskList.width = this.state.options.taskList.columns.reduce(
        (prev, current) => {
          return { width: prev.width + current.width }
        },
        { width: 0 }
      ).width
    },

    /**
     * Global resize event (from window.addEventListener)
     */
    globalOnResize() {
      if (typeof this.$el === 'undefined' || !this.$el) {
        return
      }
      this.state.options.clientWidth = this.$el.clientWidth
      if (
        this.state.options.taskList.widthFromPercentage >
        (this.state.options.clientWidth / 100) *
          this.state.options.taskList.widthThreshold
      ) {
        const diff =
          this.state.options.taskList.widthFromPercentage -
          (this.state.options.clientWidth / 100) *
            this.state.options.taskList.widthThreshold
        let diffPercent =
          100 - (diff / this.state.options.taskList.widthFromPercentage) * 100
        if (diffPercent < 0) {
          diffPercent = 0
        }
        this.state.options.taskList.columns.forEach(column => {
          column.thresholdPercent = diffPercent
        })
      } else {
        this.state.options.taskList.columns.forEach(column => {
          column.thresholdPercent = 100
        })
      }
      this.calculateTaskListColumnsDimensions()
      this.$emit('calendar-recalculate')
      this.syncScrollTop()
    }
  },

  computed: {
    /**
     * Get visible tasks
     * Very important method which will bring us only those tasks that are visible inside gantt chart
     * For example when task is collapsed - children of this task are not visible - we should not render them
     */
    visibleTasks() {
      const visibleTasks = this.state.tasks.filter(task =>
        this.isTaskVisible(task)
      )
      const maxRows = visibleTasks.slice(0, this.state.options.maxRows)
      this.state.options.rowsHeight = this.getTasksHeight(maxRows)
      let heightCompensation = 0
      if (
        this.state.options.maxHeight &&
        this.state.options.rowsHeight > this.state.options.maxHeight
      ) {
        heightCompensation =
          this.state.options.rowsHeight - this.state.options.maxHeight
        this.state.options.rowsHeight = this.state.options.maxHeight
      }
      this.state.options.height = this.getHeight(maxRows) - heightCompensation
      this.state.options.allVisibleTasksHeight = this.getTasksHeight(
        visibleTasks
      )
      this.state.options.outerHeight =
        this.getHeight(maxRows, true) - heightCompensation
      const len = visibleTasks.length
      for (let index = 0; index < len; index++) {
        const task = visibleTasks[index]
        task.width =
          task.duration / this.state.options.times.timePerPixel -
          this.style['grid-line-vertical']['stroke-width']
        if (task.width < 0) {
          task.width = 0
        }
        task.height = this.state.options.row.height
        task.x = this.timeToPixelOffsetX(task.startTime)
        task.y =
          (this.state.options.row.height +
            this.state.options.chart.grid.horizontal.gap * 2) *
            index +
          this.state.options.chart.grid.horizontal.gap
      }
      return visibleTasks
    },

    /**
     * Style shortcut
     */
    style() {
      return this.state.dynamicStyle
    },

    /**
     * Get columns and compute dimensions on the fly
     */
    getTaskListColumns() {
      this.calculateTaskListColumnsDimensions()
      return this.state.options.taskList.columns
    },

    /**
     * Tasks used for communicate with parent component
     */
    outputTasks() {
      return this.state.tasks
    },

    /**
     * Options used to communicate with parent component
     */
    outputOptions() {
      return this.state.options
    }
  },

  /**
   * Watch tasks after gantt instance is created and react when we have new kids on the block
   */
  created() {
    this.initializeEvents()
    this.setup()
    this.state.unwatchTasks = this.$watch(
      'tasks',
      tasks => {
        const notEqual = notEqualDeep(tasks, this.outputTasks)
        if (notEqual) {
          this.setup('tasks')
        }
      },
      { deep: true }
    )
    this.state.unwatchOptions = this.$watch(
      'options',
      opts => {
        const notEqual = notEqualDeep(opts, this.outputOptions)
        if (notEqual) {
          this.setup('options')
        }
      },
      { deep: true }
    )
    this.state.unwatchStyle = this.$watch(
      'dynamicStyle',
      style => {
        const notEqual = notEqualDeep(style, this.dynamicStyle)
        if (notEqual) {
          this.initializeStyle()
        }
      },
      { deep: true, immediate: true }
    )

    this.state.unwatchOutputTasks = this.$watch(
      'outputTasks',
      tasks => {
        this.$emit('tasks-changed', tasks.map(task => task))
      },
      { deep: true }
    )
    this.state.unwatchOutputOptions = this.$watch(
      'outputOptions',
      options => {
        this.$emit('options-changed', mergeDeep({}, options))
      },
      { deep: true }
    )
    this.state.unwatchOutputStyle = this.$watch(
      'style',
      style => {
        this.$emit('dynamic-style-changed', mergeDeep({}, style))
      },
      { deep: true }
    )

    this.$root.$emit('gantt-elastic-created', this)
    this.$emit('created', this)
  },

  /**
   * Emit before-mount event
   */
  beforeMount() {
    this.$emit('before-mount', this)
  },

  /**
   * Emit ready/mounted events and deliver this gantt instance to outside world when needed
   */
  mounted() {
    this.state.options.clientWidth = this.$el.clientWidth
    this.state.resizeObserver = new ResizeObserver((entries, observer) => {
      this.globalOnResize()
    })
    this.state.resizeObserver.observe(this.$el.parentNode)
    this.globalOnResize()
    this.$emit('ready', this)
    this.$root.$emit('gantt-elastic-mounted', this)
    this.$emit('mounted', this)
    this.$root.$emit('gantt-elastic-ready', this)
  },

  /**
   * Emit event when data was changed and before update (you can cleanup dom events here for example)
   */
  beforeUpdate() {
    this.$emit('before-update')
  },

  /**
   * Emit event when gantt-elastic view was updated
   */
  updated() {
    this.$nextTick(() => {
      this.$emit('updated')
    })
  },

  /**
   * Before destroy event - clean up
   */
  beforeDestroy() {
    this.state.resizeObserver.unobserve(this.$el.parentNode)
    this.state.unwatchTasks()
    this.state.unwatchOptions()
    this.state.unwatchStyle()
    this.state.unwatchOutputTasks()
    this.state.unwatchOutputOptions()
    this.state.unwatchOutputStyle()
    this.$emit('before-destroy')
  },

  /**
   * Emit event after gantt-elastic was destroyed
   */
  destroyed() {
    this.$emit('destroyed')
  }
}
export default GanttElastic
</script>

<style>
[class^="gantt-elastic"],
[class*=" gantt-elastic"] {
  box-sizing: border-box;
}
.gantt-elastic__main-view svg {
  display: block;
}
.gantt-elastic__grid-horizontal-line,
.gantt-elastic__grid-vertical-line {
  stroke: #a0a0a0;
  stroke-width: 1;
}
foreignObject > * {
  margin: 0px;
}
.gantt-elastic .p-2 {
  padding: 10rem;
}
.gantt-elastic__main-view-main-container,
.gantt-elastic__main-view-container {
  overflow: hidden;
  max-width: 100%;
}
.gantt-elastic__task-list-header-column:last-of-type {
  border-right: 1px solid #00000050;
}
.gantt-elastic__task-list-item:last-of-type {
  border-bottom: 1px solid #00000050;
}
.gantt-elastic__task-list-item-value-wrapper:hover {
  overflow: visible !important;
}
.gantt-elastic__task-list-item-value-wrapper:hover
  > .gantt-elastic__task-list-item-value-container {
  position: relative;
  overflow: visible !important;
}
.gantt-elastic__task-list-item-value-wrapper:hover
  > .gantt-elastic__task-list-item-value {
  position: absolute;
}
</style>
