timelime.js

import _ from 'lodash';
import Utility from './utility';
import Graphics from './graphics';
import Text from './text';

/**
 * 차트들을 시간별로 볼 수 있는 Timline 기능을 담당하는 클래스
 * @alias Timeline
 */
export default class Timeline {
  /**
   * 타임라인 생성에 필요한 각종 값들을 설정하고 마우스 이벤트를 등록한다.
   * @param {object} _objects 타임라인 생성에 필요한 각종 오브젝트들
   * @param {object} _datasets 타임라인 생성에 필요한 데이터셋 전체
   */
  constructor(_objects, _datasets) {
    /**
     * @description Chart.js에서 생성된 차트 오브젝트
     * @type { ChartJS }
     */
    this.chart = _objects.chart;

    /**
     * @description 차트를 생성할 때 사용자들이 설정했던 옵션
     * @type { object }
     */
    this.option = _objects.option;

    /**
     * @description Chart 클래스에서 생성하지만 부모는 Chart클래스의 부모와 동일하게 설정
     * @type { Gamify }
     */
    this.parent = _objects.parent;

    /**
     * @description 타임라인 생성에 필요한 데이터셋 전체
     * @type { object }
     */
    this.datasets = _datasets;

    /**
     * @description PIXI 오브젝트
     * @type { PIXI }
     */
    this.pixi = Utility.makePixi(this.option, true);

    /**
     * @description Timeline에 bar를 그려줄 Graphics
     * @type { Graphics }
     */
    this.barGraphics = new Graphics(this.pixi);
    this.barGraphics.setInteractive(true);

    /**
     * @description Timeline에 control을 그려줄 Graphics
     * @type { Graphics }
     */
    this.controlGraphics = new Graphics(this.pixi);
    this.controlGraphics.setInteractive(true);

    const { ratio } = this.parent;
    const { rootSize } = this.option;
    const marginLR = 10; // percent

    /**
     * @description 좌우 여백 퍼센트의 실제 값
     * @type { number }
     */
    this.marginLRValue = rootSize.width * (marginLR / 100);

    /**
     * @description Timeline의 주 색상 ( 16진수 )
     * @type { number }
     */
    this.color = 0x0288D1;

    /**
     * @description control의 원 크기
     * @type { number }
     */
    this.radius = ratio.mid * 10;

    /**
     * @description Timeline의 투명도
     * @type { number }
     */
    this.alpha = 0.5;

    /**
     * @description control의 x좌표
     * @type { number }
     */
    this.circleX = this.marginLRValue;

    /**
     * @description 현재 Timeline의 위치
     * @type { number }
     */
    this.current = 0;

    /**
     * @description control에 mouseover 여부
     * @type { boolean }
     */
    this.controlIn = false;

    /**
     * @description control에 mounsedown 여부
     * @type { boolean }
     */
    this.controlDown = false;
    const circleStart = this.marginLRValue;
    const circleEnd = rootSize.width - this.marginLRValue * 2 + circleStart;
    const barLength = circleEnd - circleStart;

    /**
     * @description Timeline의 전체 넓이에서 하나의 넓이
     * @type { number }
     */
    this.hitLength = barLength / (this.datasets.length - 1);

    /**
     * @description Timeline에서 시간별 위치
     * @type { array }
     */
    this.hitPoint = [];

    /**
     * @description Timeline의 텍스트들
     * @type { Text }
     */
    this.text = [];
    const barY = rootSize.height * 0.9 + this.radius / 4;
    for (let i = 0; i < this.datasets.length; i++) {
      this.hitPoint.push(i * this.hitLength + circleStart);
      this.text.push(new Text(this.pixi, this.timeToString(i), {}));
      this.text[i].setPosition(this.hitPoint[i], barY + this.text[i].object.height);
      this.text[i].setAlpha(0.3);
      let textScale = ratio.mid * (50 / (this.text[i].object.width));
      textScale = textScale > 0.9 ? 0.9 : textScale;
      this.text[i].setScale(textScale);
    }
    this.text[0].setAlpha(1);


    this.drawTimeline();

    this.barGraphics.object.on('mouseover', () => {
      this.drawTimeline('bar-in');
    });

    this.barGraphics.object.on('mouseout', () => {
      if (!this.controlIn) { this.drawTimeline('out'); }
    });

    this.controlGraphics.object.on('mouseover', () => {
      this.controlIn = true;
      this.drawTimeline('control-in');
    });

    this.controlGraphics.object.on('mouseout', () => {
      this.controlIn = false;
      this.drawTimeline('out');
    });

    this.controlGraphics.object.on('mousedown', (e) => {
      this.controlDown = true;
      this.drawTimeline('control-down', e);
    });

    this.controlGraphics.object.on('mouseup', (e) => {
      this.controlDown = false;
      this.drawTimeline('control-up', e);
      this.changeDataset(this.index);
    });

    this.controlGraphics.object.on('mousemove', (e) => {
      if (this.controlDown) { this.drawTimeline('control-move', e); }
    });

    this.parent.interaction.addKeyboardEvent('ArrowLeft', () => {
      const next = this.current - 1 < 0 ? 0 : this.current - 1;
      this.changeDataset(next);
      this.drawTimeline('force');
    });

    this.parent.interaction.addKeyboardEvent('ArrowRight', () => {
      const next = this.current + 1 > this.datasets.length - 1 ? this.datasets.length - 1 : this.current + 1;
      this.changeDataset(next);
      this.drawTimeline('force');
    });
  }


  /**
   * 인덱스를 통해 데이터셋을 변경시켜 데이터의 시간을 바꾼다.
   * @param {number} index 변경시킬 인덱스
   * @memberof Timeline
   * @instance
   */
  changeDataset(index) {
    this.text[this.current].setAlpha(0.3);
    for (let i = 0; i < this.text.length; i++) {
      this.text[i].setAlpha(0.3);
    }
    this.text[index].setAlpha(1);
    this.current = index;
    const datasets = [_.cloneDeep(this.datasets[index])];
    this.parent.update(datasets);
    // const dataset = this.datasets[index];
    // this.chart.config.data.datasets[0].data = dataset.data;
    // this.chart.update();
  }

  /**
   * Timeline을 현재 상태를 바탕으로 그려줌
   * @param {string} state 현재 Timeline에 가해진 이벤트 상태
   * @param {object} e 이벤트 상테에서 가져온 event 오브젝트 ( 마우스 위치 )
   * @memberof Timeline
   * @instance
   */
  drawTimeline(state, e) {
    const { rootSize } = this.option;
    let controlBackgroundRadiusMagnification = 1;
    switch (state) {
      case 'out':
        this.alpha = 0.5;
        break;
      case 'bar-in':
        this.alpha = 1;
        break;
      case 'control-in':
        this.alpha = 1;
        controlBackgroundRadiusMagnification = 1.4;
        break;
      case 'control-down':
        controlBackgroundRadiusMagnification = 2;
        break;
      case 'control-up':
        controlBackgroundRadiusMagnification = 1.4;
        break;
      case 'control-move':
        controlBackgroundRadiusMagnification = 2;
        break;
      default:
        break;
    }

    this.barGraphics.object.clear();
    this.barGraphics.object.beginFill(this.color, this.alpha / 2);
    this.barGraphics.object.drawRect(this.marginLRValue, rootSize.height * 0.9, rootSize.width - this.marginLRValue * 2, this.radius / 2);
    for (let i = 0; i < this.hitPoint.length; i++) {
      this.barGraphics.object.drawCircle(this.hitPoint[i], rootSize.height * 0.9 + this.radius / 4, 5);
    }
    this.barGraphics.object.endFill();

    this.controlGraphics.object.clear();

    const circleStart = this.marginLRValue;
    const circleEnd = rootSize.width - this.marginLRValue * 2 + circleStart;
    this.circleX = _.isUndefined(e) ? this.circleX : e.data.global.x;
    this.circleX = this.circleX < circleStart ? circleStart : this.circleX;
    this.circleX = this.circleX > circleEnd ? circleEnd : this.circleX;

    if (state === 'control-up') {
      this.index = this.findNearest(this.hitPoint, this.circleX);
      this.circleX = this.hitPoint[this.index];
    } else if (this.controlDown) {
      for (let i = 0; i < this.hitPoint.length; i++) {
        if (this.circleX >= this.hitPoint[i] - this.hitLength * 0.2 && this.circleX <= this.hitPoint[i] + this.hitLength * 0.2) {
          this.circleX = this.hitPoint[i];
          this.changeDataset(i);
        }
      }
      if (this.circleX > this.current * this.hitLength + this.marginLRValue) {
        const nextIndex = this.current + 1;
        const nextAlpha = ((this.circleX - this.marginLRValue) / this.hitLength) - this.current;
        const currentAlpha = 1 - nextAlpha;
        if (nextIndex <= this.datasets.length - 1) {
          this.text[this.current].setAlpha(currentAlpha);
          this.text[nextIndex].setAlpha(nextAlpha);
        }
      } else if (Math.abs(this.circleX - (this.current * this.hitLength + this.marginLRValue)) > 1) {
        const nextIndex = this.current - 1;
        const nextAlpha = this.current - ((this.circleX - this.marginLRValue) / this.hitLength);
        const currentAlpha = 1 - nextAlpha;
        if (nextIndex >= 0) {
          this.text[this.current].setAlpha(currentAlpha);
          this.text[nextIndex].setAlpha(nextAlpha);
        }
      }
    }
    if (_.includes(state, 'control')) {
      this.controlGraphics.object.beginFill(this.color, 0.5);
      this.controlGraphics.object.drawCircle(this.circleX, rootSize.height * 0.9 + this.radius / 4, this.radius * controlBackgroundRadiusMagnification);
      this.controlGraphics.object.endFill();
    }

    if (state === 'force') {
      this.circleX = this.marginLRValue + this.current * this.hitLength;
    }

    this.controlGraphics.object.beginFill(this.color, this.alpha);
    this.controlGraphics.object.drawCircle(this.circleX, rootSize.height * 0.9 + this.radius / 4, this.radius * 0.7);
    this.controlGraphics.object.endFill();
  }

  /**
   * 제시한 수를 배열에서 검색해서 가장 가까운 값의 인덱스를 찾아줌
   * @param {array} array 검색할 배열
   * @param {number} value 찾을 수
   * @returns {number} index
   * @memberof Timeline
   * @instance
   */
  findNearest(array, value) {
    let nearest = 0;
    for (let i = 1; i < array.length; i++) {
      const distance = Math.abs(value - array[i]);
      nearest = distance < Math.abs(value - array[nearest]) ? i : nearest;
    }
    return nearest;
  }

  /**
   * 시간을 제시한 포맷으로 변경시켜줌
   * @param {index} index 제시한 포맷으로 변경시킬 시간
   * @returns {string} time
   * @memberof Timeline
   * @instance
   */
  timeToString(index) {
    const originalTime = this.datasets[index].time.toLowerCase();
    const timeObject = new Date(originalTime);
    let format = this.option.data.timelineFormat;

    const year = timeObject.getFullYear();
    const month = timeObject.getMonth() + 1;
    const date = timeObject.getDate();
    format = _.replace(format, 'yyyy', year);

    format = _.replace(format, 'mm', month < 10 ? `0${month}` : month);
    format = _.replace(format, 'm', month);

    format = _.replace(format, 'dd', date < 10 ? `0${date}` : date);
    format = _.replace(format, 'd', date);

    return format;
  }
}