conveyal/modeify

View on GitHub
client/components/conveyal/transitive.js/master/lib/display/index.js

Summary

Maintainability
F
4 days
Test Coverage
var d3 = require('d3');
var debug = require('debug')('transitive:display');
var each = require('each');

var Legend = require('./legend');
var TileLayer = require('./tile-layer');

var SphericalMercator = require('../util/spherical-mercator');
var sm = new SphericalMercator();

/**
 * Expose `Display`
 */

module.exports = Display;

/**
 * The D3-based SVG display.
 *
 * @param {Object} options
 */

function Display(transitive) {
  this.transitive = transitive;
  var el = this.el = transitive.el;
  this.width = el.clientWidth;
  this.height = el.clientHeight;

  // Set up the pan/zoom behavior
  var zoom = this.zoom = d3.behavior.zoom()
    .scaleExtent([0.25, 4]);

  var self = this;

  var zoomBehavior = function() {
    self.computeScale();
    if (self.scale !== self.lastScale) { // zoom action
      self.zoomChanged();
    } else { // pan action
      setTimeout(transitive.refresh.bind(transitive, true), 0);
    }

    var llb = self.llBounds();
    debug('ll bounds: ' + llb[0][0] + ',' + llb[0][1] + ' to ' + llb[1][0] +
      ',' + llb[1][1]);
  };

  this.zoom.on('zoom.transitive', zoomBehavior);

  this.zoomFactors = transitive.options.zoomFactors || this.getDefaultZoomFactors();

  // set up the svg display
  var div = d3.select(el)
    .attr('class', 'Transitive');

  if (transitive.options.zoomEnabled) {
    div.call(zoom);
  }

  this.svg = div
    .append('svg')
    .attr('class', 'schematic-map');

  // initialize the x/y scale objects
  this.xScale = d3.scale.linear();
  this.yScale = d3.scale.linear();

  // set up the resize event handler
  if (transitive.options.autoResize) {
    d3.select(window).on('resize.display', function() {
      self.resized();
      transitive.refresh();
    });
  }

  // set the scale
  var bounds;
  if (transitive.options.initialBounds) {
    bounds = [sm.forward(transitive.options.initialBounds[0]),
      sm.forward(transitive.options.initialBounds[1])
    ];
  } else if (transitive.network && transitive.network.graph) {
    bounds = transitive.network.graph.bounds();
  }

  if (bounds) {
    this.setScale(bounds, transitive.options);
    this.updateActiveZoomFactors(this.scale);
    this.lastScale = this.scale;
  } else {
    this.updateActiveZoomFactors(1);
  }

  // set up the map layer
  if (transitive.options.mapboxId) {
    this.tileLayer = new TileLayer({
      el: this.el,
      display: this,
      graph: transitive.graph,
      mapboxId: transitive.options.mapboxId
    });
  }

  // set up the legend
  if (transitive.options.legendEl) {
    this.legend = new Legend(transitive.options.legendEl, this, transitive);
  }

  transitive.emit('initialize display', transitive, this);
  return this;
}

/**
 * zoomChanged -- called when the zoom level changes, either by through the native
 * zoom support or the setBounds() API call. Updates zoom factors as needed and
 * performs appropriate update action (render or refresh)
 */

Display.prototype.zoomChanged = function() {
  if (this.updateActiveZoomFactors(this.scale)) {
    this.transitive.network = null;
    this.transitive.render();
  } else this.transitive.refresh();
  this.lastScale = this.scale;
};

Display.prototype.updateActiveZoomFactors = function(scale) {
  var updated = false;
  for (var i = 0; i < this.zoomFactors.length; i++) {
    var min = this.zoomFactors[i].minScale;
    var max = (i < this.zoomFactors.length - 1) ?
      this.zoomFactors[i + 1].minScale : Number.MAX_VALUE;

    // check if we've crossed into a new zoomFactor partition
    if ((!this.lastScale || this.lastScale < min || this.lastScale >= max) &&
      scale >= min && scale < max) {
      this.activeZoomFactors = this.zoomFactors[i];
      updated = true;
    }
  }
  return updated;
};

/**
 * Return default zoom factors
 */

Display.prototype.getDefaultZoomFactors = function(data) {
  return [{
    minScale: 0,
    gridCellSize: 25,
    internalVertexFactor: 1000000,
    angleConstraint: 45,
    mergeVertexThreshold: 200
  }, {
    minScale: 1.5,
    gridCellSize: 0,
    internalVertexFactor: 0,
    angleConstraint: 5,
    mergeVertexThreshold: 0
  }];
};

/**
 * Empty the display
 */

Display.prototype.empty = function() {
  debug('emptying svg');
  this.svg.selectAll('*').remove();

  this.haloLayer = this.svg.insert('g', ':first-child');
};

/**
 * Set the scale
 */

Display.prototype.setScale = function(bounds, options) {

  this.height = this.el.clientHeight;
  this.width = this.el.clientWidth;

  var domains = getDomains(this, this.height, this.width, bounds, options);
  this.xScale.domain(domains[0]);
  this.yScale.domain(domains[1]);

  this.xScale.range([0, this.width]);
  this.yScale.range([this.height, 0]);

  debug('x scale %j -> %j', this.xScale.domain(), this.xScale.range());
  debug('y scale %j -> %j', this.yScale.domain(), this.yScale.range());

  this.zoom
    .x(this.xScale)
    .y(this.yScale);

  this.initXRes = (domains[0][1] - domains[0][0]) / this.width;
  this.scale = 1;

  this.scaleSet = true;
};

Display.prototype.computeScale = function() {
  var newXRes = (this.xScale.domain()[1] - this.xScale.domain()[0]) / this.width;
  this.scale = this.initXRes / newXRes;
};

/**
 * updateDomains -- set x/y domains of geographic (spherical mercator) coordinate
 * system. Does *not* check/adjust aspect ratio.
 */

Display.prototype.updateDomains = function(bounds) {
  this.xScale.domain([bounds[0][0], bounds[1][0]]);
  this.yScale.domain([bounds[0][1], bounds[1][1]]);

  this.zoom
    .x(this.xScale)
    .y(this.yScale);

  this.computeScale();
};

Display.prototype.resized = function() {

  var newWidth = this.el.clientWidth;
  var newHeight = this.el.clientHeight;

  var xDomain = this.xScale.domain();
  var xFactor = newWidth / this.width;
  var xDomainAdj = (xDomain[1] - xDomain[0]) * (xFactor - 1) / 2;
  this.xScale.domain([xDomain[0] - xDomainAdj, xDomain[1] + xDomainAdj]);

  var yDomain = this.yScale.domain();
  var yFactor = newHeight / this.height;
  var yDomainAdj = (yDomain[1] - yDomain[0]) * (yFactor - 1) / 2;
  this.yScale.domain([yDomain[0] - yDomainAdj, yDomain[1] + yDomainAdj]);

  this.xScale.range([0, newWidth]);
  this.yScale.range([newHeight, 0]);

  this.height = newHeight;
  this.width = newWidth;

  this.zoom
    .x(this.xScale)
    .y(this.yScale);
};

Display.prototype.xyBounds = function() {
  var x = this.xScale.domain();
  var y = this.yScale.domain();
  return [
    [x[0], y[0]],
    [x[1], y[1]]
  ];
};

/**
 * Lat/lon bounds
 */

Display.prototype.llBounds = function() {
  var x = this.xScale.domain();
  var y = this.yScale.domain();

  return [
    sm.inverse([x[0], y[0]]),
    sm.inverse([x[1], y[1]])
  ];
};

Display.prototype.isInRange = function(x, y) {
  var xRange = this.xScale.range();
  var yRange = this.yScale.range();

  return x >= xRange[0] && x <= xRange[1] && y >= yRange[1] && y <= yRange[0];
};

/**
 * Compute the x/y coordinate space domains to fit the graph.
 */

function getDomains(display, height, width, bounds, options) {
  var xmin = bounds[0][0],
    xmax = bounds[1][0];
  var ymin = bounds[0][1],
    ymax = bounds[1][1];
  var xRange = xmax - xmin;
  var yRange = ymax - ymin;

  var paddingFactor = (options && options.paddingFactor) ?
    options.paddingFactor : 0.1;

  var margins = getMargins(options);

  var usableHeight = height - margins.top - margins.bottom;
  var usableWidth = width - margins.left - margins.right;
  var displayAspect = width / height;
  var usableDisplayAspect = usableWidth / usableHeight;
  var graphAspect = xRange / (yRange === 0 ? -Infinity : yRange);

  var padding;
  var dispX1, dispX2, dispY1, dispY2;
  var dispXRange, dispYRange;

  if (usableDisplayAspect > graphAspect) { // y-axis is limiting
    padding = paddingFactor * yRange;
    dispY1 = ymin - padding;
    dispY2 = ymax + padding;
    dispYRange = yRange + 2 * padding;
    var addedYRange = (height / usableHeight * dispYRange) - dispYRange;
    if (margins.top > 0 || margins.bottom > 0) {
      dispY1 -= margins.bottom / (margins.bottom + margins.top) * addedYRange;
      dispY2 += margins.top / (margins.bottom + margins.top) * addedYRange;
    }
    dispXRange = (dispY2 - dispY1) * displayAspect;
    var xOffset = (margins.left - margins.right) / width;
    var xMidpoint = (xmax + xmin - dispXRange * xOffset) / 2;
    dispX1 = xMidpoint - dispXRange / 2;
    dispX2 = xMidpoint + dispXRange / 2;
  } else { // x-axis limiting
    padding = paddingFactor * xRange;
    dispX1 = xmin - padding;
    dispX2 = xmax + padding;
    dispXRange = xRange + 2 * padding;
    var addedXRange = (width / usableWidth * dispXRange) - dispXRange;
    if (margins.left > 0 || margins.right > 0) {
      dispX1 -= margins.left / (margins.left + margins.right) * addedXRange;
      dispX2 += margins.right / (margins.left + margins.right) * addedXRange;
    }

    dispYRange = (dispX2 - dispX1) / displayAspect;
    var yOffset = (margins.bottom - margins.top) / height;
    var yMidpoint = (ymax + ymin - dispYRange * yOffset) / 2;
    dispY1 = yMidpoint - dispYRange / 2;
    dispY2 = yMidpoint + dispYRange / 2;
  }

  return [
    [dispX1, dispX2],
    [dispY1, dispY2]
  ];
}

function getMargins(options) {
  var margins = {
    left: 0,
    right: 0,
    top: 0,
    bottom: 0
  };

  if (options && options.displayMargins) {
    if (options.displayMargins.top) margins.top = options.displayMargins.top;
    if (options.displayMargins.bottom) margins.bottom = options.displayMargins.bottom;
    if (options.displayMargins.left) margins.left = options.displayMargins.left;
    if (options.displayMargins.right) margins.right = options.displayMargins.right;
  }

  return margins;
}