Home Manual Reference Source Repository

src/states/centered-zoom-state.js

import scales from '../utils/scales';
import BaseState from './base-state';


/**
 * `CenteredZoomState` is a timeline state mimicing the `Live` zoom interaction. It allows the user to browse the timeline by clicking on a track, and then
 * - moving down to zoom in
 * - moving up to zoom out
 * - moving left to move in time, after
 * - moving right to move in time, before
 *
 * [example usage](./examples/states-zoom.html)
 */
export default class CenteredZoomState extends BaseState {
  constructor(timeline) {
    super(timeline);
    this.currentLayer = null;
    // Set max/min zoom
    // maxZoom: 1px per sample
    // minZoom: 10 000 px per 1 hour
    // with a default to 44.1kHz sample rate
    this.maxZoom = 44100 * 16 / this.timeline.timeContext.pixelsPerSecond;
    this.minZoom = 10000 / 3600 / this.timeline.timeContext.pixelsPerSecond;
  }

  handleEvent(e) {
    switch(e.type) {
      case 'mousedown':
        this.onMouseDown(e);
        break;
      case 'mousemove':
        this.onMouseMove(e);
        break;
      case 'mouseup':
        this.onMouseUp(e);
        break;
    }
  }

  onMouseDown(e) {

    this.initialX = e.x;
    this.initialOffset = this.timeline.timeContext.offset;
    this.initialCenterTime =
      this.timeline.timeContext.timeToPixel.invert(e.x) - this.initialOffset;
    
    this.initialY = e.y;
    this.initialZoom = this.timeline.timeContext.zoom;

    this.dragMode = 'unresolved';

    this._pixelToExponent = scales.linear()
      .domain([0, 100]) // 100px => factor 2
      .range([0, 1]);
  }

  updateDragMode(e) {

    if (this.dragMode === 'free') {
      return;
    }
    
    const dx = Math.abs(e.x - this.initialX);
    const dy = Math.abs(e.y - this.initialY);

    const smallThreshold = 10, bigThreshold = 50;

    if (this.dragMode === 'unresolved') {
      if (dy > smallThreshold && dy > dx * 2) {
        this.dragMode = 'vertical';
      } else if (dx > smallThreshold && dx > dy * 2) {
        this.dragMode = 'horizontal';
      } else if (dx > smallThreshold && dy > smallThreshold) {
        this.dragMode = 'free';
      }
    }

    if (this.dragMode === 'vertical' && dx > bigThreshold) {
      this.dragMode = 'free';
    }
    if (this.dragMode === 'horizontal' && dy > bigThreshold) {
      this.dragMode = 'free';
    }
  }

  onMouseMove(e) {
    // prevent annoying text selection when dragging
    e.originalEvent.preventDefault();

    this.updateDragMode(e);
    
    const timeContext = this.timeline.timeContext;

    let changed = false;
    
    if (this.dragMode === 'vertical' ||
        this.dragMode === 'free') {
      
      const exponent = this._pixelToExponent(e.y - this.initialY);

      // -1...1 -> 1/2...2 :
      const targetZoom = this.initialZoom * Math.pow(2, exponent);

      const clampedZoom = Math.min(Math.max(targetZoom, this.minZoom),
                                   this.maxZoom);

      if (timeContext.zoom !== clampedZoom) {
        timeContext.zoom = clampedZoom;
        changed = true;
      }
    }

    // We want to keep the same time under the mouse as we originally
    // had (this.initialCenterTime). We actually need to do this
    // regardless of drag mode -- even if we're only intending to drag
    // vertically (i.e. zooming), we still want to ensure the point
    // under the mouse doesn't wander off
    const timeMovedTo =
          timeContext.timeToPixel(this.initialCenterTime +
                                  timeContext.offset);
    
    const delta = e.x - timeMovedTo;
    const deltaTime = timeContext.timeToPixel.invert(delta);

    if (deltaTime !== 0) {
      timeContext.offset += deltaTime;
      changed = true;
    }

    console.log("mouse move: time context offset is now " + timeContext.offset);
    
    // Other possible experiments with centered-zoom-state
    //
    // Example 1: Prevent timeline.offset to be negative
    // timeContext.offset = Math.min(timeContext.offset, 0);
    //
    // Example 2: Keep in container when zoomed out
    // if (timeContext.stretchRatio < 1) {
    //   const minOffset = timeContext.timeToPixel.invert(0);
    //   const maxOffset = timeContext.timeToPixel.invert(view.width - timeContext.timeToPixel(timeContext.duration));
    //   timeContext.offset = Math.max(timeContext.offset, minOffset);
    //   timeContext.offset = Math.min(timeContext.offset, maxOffset);
    // }

    if (changed) {
      this.timeline.tracks.update();
    }
  }

  onMouseUp(e) {
    this.dragMode = 'unresolved';
  }
}