graphics2d/horizontal1DTrack.js

/*
 * @Description:
 * @Author: Hongpeng Ma
 * @Github: gitlab.com/hongpengm
 * @Date: 2019-03-24 22:39:51
 * @LastEditTime: 2019-03-29 02:12:17
 */
'use strict'
import { HiCEvent } from '../events/events'
import { argsParser } from '../utils/args'
const d3 = require('d3')
const uidv4 = require('uuid/v4')
const EventEmitter = require('events').EventEmitter
const HORIZONTAL1D_MARGIN_TOP = 0.05
const HORIZONTAL1D_MARGIN_BOTTOM = 0.05
const HORIZONTAL1D_MARGIN_RIGHT = 0.05
const HORIZONTAL1D_MARGIN_LEFT = 0.05
const HORIZONTAL1D_LINE_STROKE_WIDTH = 1.5
const HORIZONTAL1D_CIRCLE_RADIUS = 5
const HORIZONTAL1D_SVG_CLASS = 'horizontal_1d_track_svg'
const HORIZONTAL1D_BRUSH_CLASS = 'horizontal_1d_track_brush'
const HORIZONTAL1D_X_AXIS_TICK_PIXEL = 40
const HORIZONTAL1D_Y_AXIS_TICK_PIXEL = 20
const HORIZONTAL1D_PATH_TYPE = 'line' // ['areaPath', 'line','bar']

/**
 *
 *
 * @class Horizontal1DTrack
 * @extends {EventEmitter}
 * @property {function} each iterate a fucntion to all Horizontal1DTrack's instances
 * @property {Array} instances all instances of this class
 * @property {function} findInstanceBySvg find a track instance given a svg element
 */
class Horizontal1DTrack extends EventEmitter {
  /**
   *Creates an instance of Horizontal1DTrack.
   * @constructor
   * @param {*} parentDOM dom element that this track will bind to
   * @param {*} [margin={
   *     top: HORIZONTAL1D_MARGIN_TOP,
   *     bottom: HORIZONTAL1D_MARGIN_BOTTOM,
   *     right: HORIZONTAL1D_MARGIN_RIGHT,
   *     left: HORIZONTAL1D_MARGIN_LEFT
   *   }]
   * margin of this track
   * @param {*} [options={
   *     line_stroke_width: HORIZONTAL1D_LINE_STROKE_WIDTH,
   *     circle_radius: HORIZONTAL1D_CIRCLE_RADIUS
   *   }]
   * extra options of this track
   * line_stroke_width is the line width
   * circle_radius is the circle mark point's radius
   * @memberof Horizontal1DTrack
   * @property {Document.Element} baseDOM - base DOM element that all other elements bind to
   * @property {number} width
   * @property {number} height
   * @property {svg} svg - parent svg
   * @property {g} svgg - g element that svg's transform to bind
   * @property {svg:clipPath} clip_path - mask
   * @property {Object} options - track options
   * @property {Array} subscribe - events that this track has subscribed
   * @property {Array} subscribers - object that subscribed this track
   * @property {String} behaviour - current behaviour
   * @property {*} zoom_handler - default zoom behaviour handler
   * @property {*} brush_handler - default brush behaviour handler
   * @property {*} drag_handler - default drag behaviour handler #Not implemented
   * @property {d3.axisBottom} xAxis
   * @property {d3.axisLeft} yAxis
   * @property {g} gX
   * @property {g} gY
   * @property {d3.scale} x_scale
   * @property {d3.scale} y_scale
   */
  constructor (parentDOM, otherArgs) {
    super()
    let marginDefault = {
      top: HORIZONTAL1D_MARGIN_TOP,
      bottom: HORIZONTAL1D_MARGIN_BOTTOM,
      right: HORIZONTAL1D_MARGIN_RIGHT,
      left: HORIZONTAL1D_MARGIN_LEFT
    }
    let optionsDefault = {
      line_stroke_width: HORIZONTAL1D_LINE_STROKE_WIDTH,
      circle_radius: HORIZONTAL1D_CIRCLE_RADIUS,
      path_type: HORIZONTAL1D_PATH_TYPE
    }
    const parsedArgs = argsParser(otherArgs, {
      options: optionsDefault,
      margin: marginDefault
    })
    let {options, margin} = parsedArgs
 

    // if (otherArgs.hasOwnProperty('margin')) {
    //  Object.keys(otherArgs.margin).forEach(k => {
    //    margin[k] = otherArgs.margin[k]
    //  })
    // }
    this.initBBox(parentDOM, margin)

    this.svg = d3.select(this.baseDOM).append('svg')
      .attr('width', this.width + this.margin.left + this.margin.right)
      .attr('height', this.height + this.margin.top + this.margin.bottom)
      .attr('class', HORIZONTAL1D_SVG_CLASS)
      .attr('id', uidv4())
    this.clip_path = this.svg.append('svg:clipPath')
      .attr('id', 'clip')
      .append('svg:rect')
      .attr('width', this.width)
      .attr('height', this.height)
      .attr('x', 0)
      .attr('y', 0)
    this.svgg = this.svg.append('g')
      .attr('width', this.width)
      .attr('height', this.height)
      .attr('transform', 'translate(' + this.margin.left + ',' + this.margin.top + ')')
    this.options = options
    //    console.log(options)
    this.subscribe = []
    this.subscribers = []
    this.initBehaviourHandler()
    this.init()
    if (Horizontal1DTrack.instances === undefined) {
      Horizontal1DTrack.instances = []
    }
    Horizontal1DTrack.instances.push(this)
  }

  /// //////////////////////////////////////////////////////////////////////////
  //                                   Init                                  //
  /// //////////////////////////////////////////////////////////////////////////

  /**
   * Init function, could be rewrite
   *
   * @memberof Horizontal1DTrack
   */
  init () { }

  /**
   * Init the bounding box and offset of the track element
   *
   * @param {Document.Element} parentDOM
   * @param {*} margin
   * @memberof Horizontal1DTrack
   */
  initBBox (parentDOM, margin) {
    let parentBBox = parentDOM.getBoundingClientRect()
    let parentHeight = parentBBox.height
    let parentWidth = parentBBox.width
    this.margin = {
      top: margin.top * parentHeight > 20
        ? margin.top * parentHeight
        : 20,
      bottom: margin.bottom * parentHeight > 20
        ? margin.bottom * parentHeight
        : 20,
      right: margin.right * parentWidth > 20
        ? margin.right * parentWidth
        : 20,
      left: margin.left * parentWidth > 20
        ? margin.left * parentWidth
        : 20
    }
    this.width = parentWidth
      ? parentWidth - this.margin.left - this.margin.right
      : window.innerWidth - this.margin.left - this.margin.right
    this.height = parentHeight
      ? parentHeight - this.margin.top - this.margin.bottom
      : window.innerHeight - this.margin.top - this.margin.bottom
    let base = document.createElement('div')
    d3.select(base)
      .attr('id', uidv4())
      .style('position', 'absolute')
      .style('width', parentWidth + 'px')
      .style('height', parentHeight + 'px')
    this.baseDOM = base
    parentDOM.appendChild(this.baseDOM)
  }

  /**
   * Init the behaviour handler and behaviour relative settings
   *
   * @memberof Horizontal1DTrack
   */
  initBehaviourHandler () {
    this.zoom_handler = d3.zoom()
      .scaleExtent([1, 8])
      .on('zoom', this.zoomed)
    this.brush_handler = d3.brushX()
      .extent([[0, 0], [this.width, this.height]])
      .on('brush start', this.brushed)
      .on('end', this.brushended)
    this.drag_handler = d3.drag()
      .on('drag', this.dragedVertical)
    this.behaviour = undefined
  }

  /// //////////////////////////////////////////////////////////////////////////
  //                               Draw Content                              //
  /// //////////////////////////////////////////////////////////////////////////

  /**
   * Draw content
   *
   * @param {function} [fn=undefined] and external draw command function
   * @memberof Horizontal1DTrack
   */
  draw (fn = undefined) {
    if (fn !== undefined) {
      fn(this)
    }
    this.drawAxis()
    this.drawPath()
    if (this.options.path_type === 'line' ||
	this.options.path_type === 'undefined') {
      this.drawPoints()
    }
  }

  /**
   * Draw axis
   *
   * @memberof Horizontal1DTrack
   */
  drawAxis () {
    this.xAxis = d3.axisBottom(this.xScale)
      .ticks(Math.round(this.width / HORIZONTAL1D_X_AXIS_TICK_PIXEL))
    this.yAxis = d3.axisLeft(this.yScale)
      .ticks(Math.round(this.height / HORIZONTAL1D_Y_AXIS_TICK_PIXEL))
    this.gX = this.svgg.append('g')
      .attr('class', 'x axis')
      .attr('transform', 'translate(0,' + this.height + ')')
      .call(this.xAxis)
    this.gY = this.svgg.append('g')
      .attr('class', 'y axis')
      .call(this.yAxis)
  }

  /**
   * Draw path
   *
   * @memberof Horizontal1DTrack
   */
  drawPath () {
    if (this.path) {
      delete this.path
    }
    this.clip_mask = this.svgg.append('g')
      .attr('clip-path', 'url(#clip)')
    //    console.log(this.options.path_type)
    switch (this.options.path_type) {
      case 'line':

        this.path = this.clip_mask.append('path')
          .datum(this.dataSet)
          .attr('class', 'line')
          .style('fill', 'none')
          .style('stroke', 'black')
          .style('stroke-width', this.options.line_stroke_width)
          .attr('d', this.shape)
        break
      case 'areaPath':
        this.path = this.clip_mask.append('path')
          .attr('class', 'area')
          .attr('fill', 'steelblue')
          .datum(this.dataSet)
          .attr('d', this.shape)
        //      console.log('here', this.dataSet)
        break
      case 'bar':
        console.log('here', this.dataSet)
        this.bars = this.clip_mask.append('g')
        this.bars
          .selectAll()
          .data(this.dataSet)
          .enter()
          .append('rect')
          .attr('x', d => { return this.xScale(d.x) })
          .attr('y', d => { return this.yScale(d.y) })
          .attr('height', d => { return this.height - this.yScale(d.y) })
          .attr('width', this.xScale.bandwidth())
        //      console.log('here', this.dataSet)
        break
      default:
        this.path = this.clip_mask.append('path')
          .datum(this.dataSet)
          .attr('class', 'line')
          .style('fill', 'none')
          .style('stroke', 'black')
          .style('stroke-width', this.options.line_stroke_width)
          .attr('d', this.shape)
    }
  }

  /**
   * Draw data points
   *
   * @memberof Horizontal1DTrack
   */
  drawPoints () {
    this.points = this.clip_mask.selectAll('.dot')
      .data(this.dataSet)
      .enter().append('circle')
      .attr('class', 'dot')
      .attr('cx', d => { return this.xScale(d.x) })
      .attr('cy', d => { return this.yScale(d.y) })
      .attr('r', this.options.circle_radius)
      .on('mouseover', function (a, b, c) {
        this.attr('class', 'focus')
      })
      .on('mouseout', null)
  }

  /// //////////////////////////////////////////////////////////////////////////
  //                               Access Data                               //
  /// //////////////////////////////////////////////////////////////////////////

  /**
   * Get data set
   * @return {*} data set of the track
   * @memberof Horizontal1DTrack
   */
  get dataSet () {
    return this.data_set
  }

  /**
   * Set dataset
   *
   * @memberof Horizontal1DTrack
   */
  set dataSet (x) {
    this.data_set = x
  }

  /**
   * set x scale
   *
   * @memberof Horizontal1DTrack
   */
  set xScale (x) {
    switch (this.options.path_type) {
      case 'areaPath':
      case 'line':
        this.x_scale = d3.scaleLinear()
          .domain(x.domain)
          .range(x.range)
        break
      case 'bar':
        this.x_scale = d3.scaleBand()
          .range(x.range)
          .domain(x.domain)
          .bandwidth(0.2)
          .padding(0.2)
    }
  }
  get xScale () {
    return this.x_scale
  }
  set yScale (x) {
    this.y_scale = d3.scaleLinear()
      .domain(x.domain)
    // Flip the y axis to make it looks ok
      .range([x.range[1], x.range[0]])
  }
  get yScale () {
    return this.y_scale
  }

  /**
   * line generate function
   * @return {function} d3 line generater
   * @readonly
   * @memberof Horizontal1DTrack
   */
  get shape () {
    switch (this.options.path_type) {
      case 'line':
        return d3.line()
          .x(d => { return this.x_scale(d.x) })
          .y(d => { return this.y_scale(d.y) })
      case 'areaPath':
        return d3.area()
          .curve(d3.curveStepAfter)
          .x(d => { return this.x_scale(d.x) })
          .y0(d => { return this.y_scale(d.y) })
          .y1(d => { return this.y_scale(0) })
      default:
    }
  }

  /// //////////////////////////////////////////////////////////////////////////
  //                                Behaviour                                //
  /// //////////////////////////////////////////////////////////////////////////

  /**
   * Apply a transform to track
   *
   * @param {*} transform includes {k, x, y}
   * @param {boolean} [transformXAxis=true] default transform has effect on x axis
   * @param {boolean} [transformYAxis=false] default transform has no effect on x axis
   * @param {boolean} [emitEvents=true] whether to emit a event
   * @memberof Horizontal1DTrack
   */
  applyTransform (transform,
    transformXAxis = true,
    transformYAxis = false,
    emitEvents = true) {
    let transformString
    transformString = 'translate(' + transform.x + ',' + '0) scale(' + transform.k + ',1)'
    switch (this.options.path_type) {
      case 'line':
        this.points
          .attr('transform', transformString)
      case 'areaPath':
        this.path
          .attr('transform', transformString)
        break
      case 'bar':
        this.bars
          .attr('transform', transformString)
        break
      default:
    }
    if (this.options.path_type === 'line') {
      if (this.options.path_type === 'line') {

      }
    }
    this.svgg.selectAll('.dot').attr('r', this.options.circle_radius / transform.k)
    this.svgg.selectAll('.line').style('stroke-width', this.options.line_stroke_width)
    if (transformXAxis) {
      console.log(transform)
      this.gX.call(this.xAxis.scale(transform.rescaleX(this.xScale)))
    }
    if (transformYAxis) {
      this.gY.call(this.yAxis.scale(transform.rescaleY(this.yScale)))
    }
    if (emitEvents) {
      let emitSubEvent = new HiCEvent()
      emitSubEvent.attr('transform', transform)
      emitSubEvent.attr('sourceEvent', 'transform')
      emitSubEvent.attr('sourceObject', this)
      this.emitSubs('transform', emitSubEvent)
    }
  }

  /**
   * respond to a HiCEvent if this is not the source of the event
   *
   * @param {HiCEvent} e
   * @memberof Horizontal1DTrack
   */
  respondEvents (e) {
    if (this !== e.sourceObject) {
      switch (e.sourceEvent) {
        case 'transform':
          this.applyTransform(e.transform, true, false, false)
          break
        case 'selection':
          if (this.behaviour === 'brush') {
            // 2D Situation
            if (Array.isArray(e.selection[0])) {
              const eSelection = [[e.selection[0][0] / e.context.width,
                e.selection[0][1] / e.context.height],
              [e.selection[1][0] / e.context.width,
                e.selection[1][1] / e.context.height]]
              let selection = [Math.round(eSelection[0][0] * this.width),
			     Math.round(eSelection[1][0] * this.width)]
	    d3.brushX().move(this.brush_g, selection)
            } else { // 1D Situation

            }
          }
          break
        default:
          break
      }
    }
  }

  /**
   * Zooming action callback
   *
   * @memberof Horizontal1DTrack
   */
  zoomed () {
    const track = Horizontal1DTrack.findInstanceBySvg(this)
    Horizontal1DTrack.applyTransform(track, d3.event.transform)

    let emitSubEvent = new HiCEvent()
    Object.keys(d3.event).forEach(key => {
      emitSubEvent
        .attr(key, d3.event[key])
    })
    emitSubEvent.attr('sourceEvent', 'h1dzoom')
    track.emitSubs('h1dzoom', emitSubEvent)
  }

  /**
   * Responding callback to other's triggered zoom event
   *
   * @param {HiCEvent} hicE
   * @memberof Horizontal1DTrack
   */
  zoomedCallback (hicE) {
    this.applyTransform(hicE.transform)
  }

  dragedVertical () {

  }
  brushed () {
    const track = Horizontal1DTrack.findInstanceByBrush(this)
    let emitSubEvent = new HiCEvent()
    emitSubEvent
      .attr('selection', d3.event.selection)
      .attr('selectionType', 'x')
      .attr('context', {
        width: track.width,
        height: track.height })
      .attr('sourceEvent', 'selection')
    //  track.setSpriteTransform(d3.event.transform)
    track.emitSubs('selection', emitSubEvent)
  }
  brushended () {
    const track = Horizontal1DTrack.findInstanceByBrush(this)
    let emitSubEvent = new HiCEvent()
    emitSubEvent
      .attr('selection', d3.event.selection)
      .attr('selectionType', 'x')
      .attr('context', {
        width: track.width,
        height: track.height })
      .attr('sourceEvent', 'selectionEnds')
    //  track.setSpriteTransform(d3.event.transform)
    track.emitSubs('selectionEnds', emitSubEvent)
  }
  brushedCallback (hicE) {
    d3.brush().move(this.brush_g, hicE.selection)
  }
  addZoomBehaviour () {
    this.removeBehaviour()
    this.behaviour = 'zoom'
    this.svg.call(this.zoom_handler)
  }
  addBrushBehaviour () {
    this.removeBehaviour()
    this.behaviour = 'brush'
    this.brush_g = this.svgg.append('g')
      .attr('class', HORIZONTAL1D_BRUSH_CLASS)
      .attr('id', uidv4())
    this.brush_g
      .call(this.brush_handler)
  }
  addZoomBrushBehaviour () {
    this.removeBehaviour()
    this.behaviour = 'zoom_brush'
    this.svg.call(this.zoom_handler)
      .on('mousedown.zoom', null)
      .on('touchstart.zoom', null)
      .on('touchcancel.zoom', null)
      .on('touchend.zoom', null)
    this.brush_g = this.svgg.append('g')
      .attr('class', HORIZONTAL1D_BRUSH_CLASS)
      .attr('id', uidv4())
    this.brush_g
      .call(this.brush_handler)
  }
  removeBehaviour () {
    switch (this.behaviour) {
      case undefined:
        break
      case 'brush':
        this.brush_g.on('.brush', null)
        break
      case 'zoom':
        d3.select(this.canvas)
          .on('.zoom', null)
        break
      case 'zoom_brush':
        this.brush_g.on('.brush', null)
        d3.select(this.canvas)
          .on('.zoom', null)
        break
      default:
        console.log('Current behaviour is unknown\nCurrent type is:', this.behaviour)
        break
    }
  }
  addSubs (sub, type, cb) {
    sub.on(type, cb)
    sub.subscribe.push({
      this: this,
      type: type,
      callback: cb
    })
    this.subscribers.push({
      'type': type,
      'subscriber': sub
    })
  }
  emitSubs (type, ...args) {
    this.subscribers.forEach(sub => {
      if (sub.type === type) {
        sub.subscriber.emit(...[type].concat(args))
        // Above line is equal to below ES5 one
        // sub.subscriber.emit.apply(sub.subscriber, [type].concat(args))
      }
    })
  }
  destroy () {
    let i = 0
    while (Horizontal1DTrack.instances[i] !== this) { i++ }
    Horizontal1DTrack.instances.splice(i, 1)
  }
}

// Loop through horizontal1dtrack instances
Horizontal1DTrack.each = function (fn) {
  Horizontal1DTrack.instances.forEach(fn)
}

Horizontal1DTrack.findInstanceBySvg = function (svg) {
  let instance
  Horizontal1DTrack.each(ele => {
    if (ele.svg.node() === svg) {
      instance = ele
    }
  })
  return instance
}

Horizontal1DTrack.findInstanceByBrush = function (g) {
  let instance
  Horizontal1DTrack.each(ele => {
    if (ele.brush_g !== undefined && ele.brush_g.node() === g) {
      instance = ele
    }
  })
  return instance
}

Horizontal1DTrack.applyTransform = function (obj, transform) {
  obj.applyTransform(transform)
}

export { Horizontal1DTrack }