wavesjs / waves-ui

A library to display and edit audio data and timeseries data in the browser.
http://wavesjs.github.io/waves-ui/
BSD 3-Clause "New" or "Revised" License
122 stars 16 forks source link

Custom Segment Behavior: Shopping Cart/Trolley Queue Metaphor - Super Slow Redraw #32

Open justinlevi opened 7 years ago

justinlevi commented 7 years ago

@b-ma Hoping you might be able to take a look at this custom behavior I've been working on for a few days and give some feedback on my approach.

Here is the gist https://gist.github.com/justinlevi/fa6afbe108620c9806c39b325c17bdef

Summary: I'm trying to recreate the shopping cart/ train car queue/ (trolly) metaphor. In other words, if you drag a segment left or right, it will respect the boundary of a neighbor/sibling and then push the the neighbor/sibling along the track as well.

I have this working, kind of. The redraw is jittery and slow, which makes me think there must be a better approach.

My first attempt was to create my own queue array, and during the drag, test to see if the current item's start/end (depending on direction) overlaps with a neighbor's start/end. Then, in the overridden _move method, I am looping through my queue array calling `shape.x(segment, renderingContext.timeToPixel.invert(targetX));

After reading through the code closer, I'm noticing there is an internal mechanism to track selectedItems on the Layer class. I'm wondering if there is a built in mechanism that all selected items would receive an edit callback from the Layer?

Ultimately I'm looking for a smooth UX when dragging any number of segments left/right.

As you can see, I also clearly got a bit overboard with my destructuring syntax. I very likely have some super inefficient code. Any feedback you might have would be greatly appreciated.

Full custom behavior below as well:

import * as ui from 'waves-ui';

class CollisionSegmentBehavior extends ui.behaviors.BaseBehavior {

  segmentsData = [];
  DIRECTION = {
    LEFT: 'LEFT',
    RIGHT: 'RIGHT'
  };

  segmentIndex = (search) => {return this.segmentsData.findIndex( obj => { return obj === search }); };

  segmentValues = (renderingContext, shape, dataObj) => {
    return ({
        startX: renderingContext.timeToPixel(shape.x(dataObj)),
        // y: renderingContext.valueToPixel(shape.y(dataObj)),
        endX: renderingContext.timeToPixel(shape.x(dataObj) + shape.width(dataObj)),
        // height: renderingContext.valueToPixel(shape.height(dataObj))
      });
  };

  constructor(segmentsData) {
    super();
    // segmentsData is a reference to the data defining all your segments
    this.segmentsData = segmentsData;

    // binding
    this.isTouchingSibling = this.isTouchingSibling.bind(this);
    this.connectedSiblings = this.connectedSiblings.bind(this);
  }

  edit(renderingContext, shape, datum, dx, dy, target) {
    const classList = target.classList;
    let action = 'move';

    if (classList.contains('handler') && classList.contains('left')) {
      action = 'resizeLeft';
    } else if (classList.contains('handler') && classList.contains('right')) {
      action = 'resizeRight';
    }

    this[`_${action}`](renderingContext, shape, datum, dx, dy, target);
  }

  isTouchingSibling(renderingContext, shape, currentSegmentObj, nextSegmentObj, direction) {
    if (!nextSegmentObj) { return }

    // CONVENIENCE
    const { segmentValues, DIRECTION } = this;
    const { LEFT, RIGHT} = DIRECTION;

    const currentSegmentValues = segmentValues(renderingContext, shape, currentSegmentObj);
    const cSegStart = currentSegmentValues.startX;
    const cSegEnd = currentSegmentValues.endX;

    const nextSegmentValues = segmentValues(renderingContext, shape, nextSegmentObj);
    const nSegStart = nextSegmentValues.startX;
    const nSegEnd = nextSegmentValues.endX;

    if (direction === LEFT) {
      // Does the left edge of the current segment hit the right edge of the next segment
      return (cSegStart <= nSegEnd) ? true : false;
    }else if (direction === RIGHT) {
      // Does the right edge of the current segment hit the left edge of the next segment
      return (cSegEnd >= nSegStart) ? true : false;
    }

    return false;
  }

  connectedSiblings(renderingContext, shape, currentIndex, direction, siblings = []) {
    // CONVENIENCE
    const { DIRECTION, segmentsData, isTouchingSibling, connectedSiblings } = this;
    const { LEFT, RIGHT } = DIRECTION;

    const currentSegmentObj = this.segmentsData[currentIndex];

    // Exception cases : FIRST & LAST segments
    if (currentIndex === 0 && direction === LEFT ) { return [currentSegmentObj] }
    if (currentIndex === segmentsData.length - 1 && direction === RIGHT ) { return [currentSegmentObj] }

    const siblingIndex = (direction === LEFT) ? currentIndex - 1 : currentIndex + 1;
    const sibling = segmentsData[siblingIndex];

    // recursion :(
    if ( siblingIndex >= 0 && siblingIndex < segmentsData.length){
      connectedSiblings(renderingContext, shape, siblingIndex, direction, siblings)
    }

    const isTouching = isTouchingSibling(renderingContext, shape, currentSegmentObj, sibling, direction);
    if ( isTouching === true ){
      siblings.push(sibling);

      // TO DO: TRY SELECTING NEIGHBOR
      // Do all selected neighbors receive the edit callback when an event is triggered?
    }

    return siblings;
  }

  _move(renderingContext, shape, dataObj, dx, dy, target) {
    // convenience destructuring
    const { segmentIndex, DIRECTION, connectedSiblings } = this;
    const { LEFT, RIGHT } = DIRECTION;

    // Build Collision Train Array
    const currentIndex = segmentIndex(dataObj);
    const direction = (dx < 0) ? LEFT : RIGHT;
    const train = connectedSiblings(renderingContext, shape, currentIndex, direction);

    if (train.length === 0){
      train.push(dataObj);
    }

    // TODO: loop through and make sure siblings are set end to end

    train.forEach(sibling => {
      const x = renderingContext.timeToPixel(shape.x(sibling))
      const targetX = Math.max(x + dx, 0);
      shape.x(sibling, renderingContext.timeToPixel.invert(targetX));
    });
  }

  _resizeLeft(renderingContext, shape, datum, dx, dy, target) {
    // current values
    const x     = renderingContext.timeToPixel(shape.x(datum));
    const width = renderingContext.timeToPixel(shape.width(datum));
    // target values
    let maxTargetX  = x + width;
    let targetX     = x + dx < maxTargetX ? Math.max(x + dx, 0) : x;
    let targetWidth = targetX !== 0 ? Math.max(width - dx, 1) : width;

    shape.x(datum, renderingContext.timeToPixel.invert(targetX));
    shape.width(datum, renderingContext.timeToPixel.invert(targetWidth));
  }

  _resizeRight(renderingContext, shape, datum, dx, dy, target) {
    // current values
    const width = renderingContext.timeToPixel(shape.width(datum));
    // target values
    let targetWidth = Math.max(width + dx, 1);

    shape.width(datum, renderingContext.timeToPixel.invert(targetWidth));
  }
}

export default CollisionSegmentBehavior;
justinlevi commented 7 years ago

Update: Here is a working example using layer selection.

import * as ui from 'waves-ui';

class CollisionSegmentBehavior extends ui.behaviors.BaseBehavior {

  segmentsData = [];
  leadingSegment = undefined;
  train = [];

  DIRECTION = {
    LEFT: 'LEFT',
    RIGHT: 'RIGHT'
  };

  segmentIndex = (search) => {return this.segmentsData.findIndex( obj => { return obj === search }); };

  segmentValues = (renderingContext, shape, dataObj) => {
    return ({
        startX: renderingContext.timeToPixel(shape.x(dataObj)),
        // y: renderingContext.valueToPixel(shape.y(dataObj)),
        endX: renderingContext.timeToPixel(shape.x(dataObj) + shape.width(dataObj)),
        // height: renderingContext.valueToPixel(shape.height(dataObj))
      });
  };

  constructor(segmentsData) {
    super();
    // segmentsData is a reference to the data defining all your segments
    this.segmentsData = segmentsData;

    // binding
    this.isTouchingSibling = this.isTouchingSibling.bind(this);
  }

  edit(renderingContext, shape, datum, dx, dy, target) {
    const classList = target.classList;
    let action = 'move';

    if (classList.contains('handler') && classList.contains('left')) {
      action = 'resizeLeft';
    } else if (classList.contains('handler') && classList.contains('right')) {
      action = 'resizeRight';
    }

    this[`_${action}`](renderingContext, shape, datum, dx, dy, target);
  }

  isTouchingSibling(renderingContext, shape, currentIndex, direction) {

    // CONVENIENCE
    const { segmentValues, DIRECTION } = this;
    const { LEFT, RIGHT} = DIRECTION;

    const currentSegmentObj = this.segmentsData[currentIndex];

    const nextSegmentObj = this.segmentsData[(direction === LEFT) ? currentIndex - 1 : currentIndex + 1];
    if (!nextSegmentObj) { return }

    const currentSegmentValues = segmentValues(renderingContext, shape, currentSegmentObj);
    const cSegStart = currentSegmentValues.startX;
    const cSegEnd = currentSegmentValues.endX;

    const nextSegmentValues = segmentValues(renderingContext, shape, nextSegmentObj);
    const nSegStart = nextSegmentValues.startX;
    const nSegEnd = nextSegmentValues.endX;

    if (direction === LEFT) {
      // Does the left edge of the current segment hit the right edge of the next segment
      return (cSegStart <= nSegEnd) ? true : false;
    }else if (direction === RIGHT) {
      // Does the right edge of the current segment hit the left edge of the next segment
      return (cSegEnd >= nSegStart) ? true : false;
    }

    return false;
  }

  _move(renderingContext, shape, dataObj, dx, dy, target) {
    // convenience destructuring
    const { segmentIndex, DIRECTION, isTouchingSibling } = this;
    const { LEFT, RIGHT } = DIRECTION;

    const currentIndex = segmentIndex(dataObj);
    const direction = (dx < 0) ? LEFT : RIGHT;

    // TODO: If changing direction of drag, all selected items should be deselected expect current

    if (isTouchingSibling(renderingContext, shape, currentIndex, direction)) {
      this.select(this._layer.items[(direction === LEFT)? currentIndex - 1 : currentIndex + 1]);
    }

    const x = renderingContext.timeToPixel(shape.x(dataObj))
    var targetX = Math.max(x + dx, 0);

    // START - OVERLAP NEIGHBOR CHECK
    // TODO: This can definitely be refactored
    const nextSegmentObj = this.segmentsData[(direction === LEFT) ? currentIndex - 1 : currentIndex + 1];

    if(nextSegmentObj){

      const currentSegmentValues = this.segmentValues(renderingContext, shape, dataObj);
      const cSegStart = currentSegmentValues.startX;
      const cSegEnd = currentSegmentValues.endX;
      const cSegWidth = cSegEnd - cSegStart;

      const nextSegmentValues = this.segmentValues(renderingContext, shape, nextSegmentObj);
      const nSegStart = nextSegmentValues.startX;
      const nSegEnd = nextSegmentValues.endX;

      if (direction === LEFT && targetX < nSegEnd) {
        targetX = nSegEnd + dx;
      }
      else if (direction === RIGHT && targetX + cSegWidth > nSegStart){
        targetX = nSegStart - cSegWidth + dx;
      }

      // END - OVERLAP NEIGHBOR CHECK
    }

    shape.x(dataObj, renderingContext.timeToPixel.invert(targetX));
  }

  _resizeLeft(renderingContext, shape, datum, dx, dy, target) {
    // current values
    const x     = renderingContext.timeToPixel(shape.x(datum));
    const width = renderingContext.timeToPixel(shape.width(datum));
    // target values
    let maxTargetX  = x + width;
    let targetX     = x + dx < maxTargetX ? Math.max(x + dx, 0) : x;
    let targetWidth = targetX !== 0 ? Math.max(width - dx, 1) : width;

    shape.x(datum, renderingContext.timeToPixel.invert(targetX));
    shape.width(datum, renderingContext.timeToPixel.invert(targetWidth));
  }

  _resizeRight(renderingContext, shape, datum, dx, dy, target) {
    // current values
    const width = renderingContext.timeToPixel(shape.width(datum));
    // target values
    let targetWidth = Math.max(width + dx, 1);

    shape.width(datum, renderingContext.timeToPixel.invert(targetWidth));
  }
}

export default CollisionSegmentBehavior;