conveyal/modeify

View on GitHub
client/help-me-choose-view/index.js

Summary

Maintainability
B
4 hrs
Test Coverage
var analytics = require('../analytics')
var d3 = require('d3')
var hogan = require('hogan.js')
var log = require('../log')('help-me-choose')
var modal = require('../modal')
var RouteModal = require('../route-modal')
var routeResource = require('../route-resource')
var routeSummarySegments = require('../route-summary-segments')
var session = require('../session')
var toCapitalCase = require('to-capital-case')

var optionTemplate = hogan.compile(require('./option.html'))
var routeTemplate = hogan.compile(require('./route.html'))

var primaryFilter = 'totalCost'
var secondaryFilter = 'productiveTime'

var filters = {
  travelTime: function (a) {
    return a.time
  },
  totalCost: function (a) {
    return a.cost
  },
  walkDistance: function (a) {
    return a.walkDistance
  },
  calories: function (a) {
    return -a.calories
  },
  productiveTime: function (a) {
    return -a.productiveTime
  },
  none: function (a) {
    return 0
  }
}

/**
 * Expose `Modal`
 */

var Modal = module.exports = modal({
  closable: true,
  width: '768px',
  template: require('./template.html')
}, function (view, routes) {
  view.primaryFilter = view.find('#primary-filter')
  view.secondaryFilter = view.find('#secondary-filter')

  view.primaryFilter.querySelector('[value="none"]').remove()

  view.primaryFilter.value = primaryFilter
  view.secondaryFilter.value = secondaryFilter

  view.oneWay = true
  view.daily = true

  view.refresh()
})

/**
 * Refresh
 */

Modal.prototype.refresh = function (e) {
  if (e) e.preventDefault()
  log('refreshing')

  primaryFilter = this.primaryFilter.value
  secondaryFilter = this.secondaryFilter.value

  var i
  var thead = this.find('thead')
  var tbody = this.find('tbody')

  // Remove rows
  tbody.innerHTML = ''

  // Remove all colors
  var headers = thead.querySelectorAll('th')
  for (i = 0; i < headers.length; i++) {
    headers[i].classList.remove('primaryFilter')
    headers[i].classList.remove('secondaryFilter')
  }
  var phead = thead.querySelector('.' + primaryFilter)
  if (phead) phead.classList.add('primaryFilter')
  var shead = thead.querySelector('.' + secondaryFilter)
  if (shead) shead.classList.add('secondaryFilter')

  // Get the indexes
  var primaryFn = filters[primaryFilter]
  var secondaryFn = filters[secondaryFilter]

  // Get the multiplier
  var multiplier = this.oneWay ? 1 : 2
  multiplier *= this.daily ? 1 : 365

  // Get the route data
  var routes = this.model.map(function (r, index) {
    return getRouteData(r, multiplier, index)
  })

  // Sort by secondary first
  routes = rankRoutes(routes, primaryFn, secondaryFn)

  // Render
  for (i = 0; i < routes.length; i++) {
    var route = routes[i]
    tbody.innerHTML += this.renderRoute(route)
    var row = tbody.childNodes[i]
    var pcell = row.querySelector('.' + primaryFilter)
    var scell = row.querySelector('.' + secondaryFilter)

    if (pcell) pcell.style.backgroundColor = toRGBA(route.primaryColor, 0.25)
    if (scell) scell.style.backgroundColor = toRGBA(route.secondaryColor, 0.25)
  }

  // Track the results
  analytics.track('Viewed "Help Me Choose"', {
    plan: session.plan().generateQuery(),
    primaryFilter: primaryFilter,
    secondaryFilter: secondaryFilter,
    multiplier: multiplier
  })
}

/**
 * Append option
 */

Modal.prototype.renderRoute = function (data) {
  data.calories = data.calories ? parseInt(data.calories, 10).toLocaleString() + ' cals' : 'None'
  data.cost = data.cost ? '$' + data.cost.toFixed(2) : 'Free'
  data.emissions = data.emissions ? parseInt(data.emissions, 10) : 'None'
  data.walkDistance = data.walkDistance ? data.walkDistance + ' mi' : 'None'

  if (data.productiveTime) {
    if (data.productiveTime > 120) {
      data.productiveTime = parseInt(data.productiveTime / 60, 10).toLocaleString() + ' hrs'
    } else {
      data.productiveTime = parseInt(data.productiveTime, 10).toLocaleString() + ' min'
    }
  } else {
    data.productiveTime = 'None'
  }

  return routeTemplate.render(data)
}

/**
 * Filters
 */

Modal.prototype.filters = function () {
  var options = ''
  for (var f in filters) {
    options += optionTemplate.render({
      name: toCapitalCase(f).toLowerCase(),
      value: f
    })
  }
  return options
}

/**
 * Select this option
 */

Modal.prototype.selectRoute = function (e) {
  e.preventDefault()
  if (e.target.tagName !== 'BUTTON') return

  var index = e.target.getAttribute('data-index')
  var route = this.model[index]
  var plan = session.plan()
  var tags = route.tags(plan)
  var self = this

  routeResource.findByTags(tags, function (err, resources) {
    if (err) log.error(err)

    var routeModal = new RouteModal(route, null, {
      context: 'help-me-choose',
      resources: resources
    })
    self.hide()
    routeModal.show()
    routeModal.on('next', function () {
      routeModal.hide()
    })
  })
}

/**
 * Multipliers
 */

Modal.prototype.setOneWay = function (e) {
  this.oneWay = true
  this.setMultiplier(e)
}

Modal.prototype.setRoundTrip = function (e) {
  this.oneWay = false
  this.setMultiplier(e)
}

Modal.prototype.setDaily = function (e) {
  this.daily = true
  this.setMultiplier(e)
}

Modal.prototype.setYearly = function (e) {
  this.daily = false
  this.setMultiplier(e)
}

Modal.prototype.setMultiplier = function (e) {
  e.preventDefault()

  var button = e.target
  var parent = button.parentNode
  var buttons = parent.getElementsByTagName('button')

  for (var i = 0; i < buttons.length; i++) {
    buttons[i].classList.remove('active')
  }

  button.classList.add('active')

  this.refresh()
}

/**
 * Rank & sort the routes
 */

function rankRoutes (routes, primary, secondary) {
  var primaryDomain = [d3.min(routes, primary), d3.max(routes, primary)]
  var secondaryDomain = [d3.min(routes, secondary), d3.max(routes, secondary)]

  var primaryScale = d3.scale.linear()
    .domain(primaryDomain)
    .range([0, routes.length * 2])

  var secondaryScale = d3.scale.linear()
    .domain(secondaryDomain)
    .range([1, routes.length])

  var primaryColor = d3.scale.linear()
    .domain(primaryDomain)
    .range(['#f5a81c', '#fff'])

  var secondaryColor = d3.scale.linear()
    .domain(secondaryDomain)
    .range(['#8ec449', '#fff'])

  routes = routes.map(function (r) {
    r.primaryRank = primaryScale(primary(r))
    r.primaryColor = primaryColor(primary(r))
    r.secondaryRank = secondaryScale(secondary(r))
    r.secondaryColor = secondaryColor(secondary(r))
    r.rank = r.primaryRank + r.secondaryRank
    return r
  })

  routes.sort(function (a, b) {
    return a.rank - b.rank
  }) // lowest number first

  return routes
}

/**
 * RGB to transparent
 */

function toRGBA (rgb, opacity) {
  var c = d3.rgb(rgb)
  return 'rgba(' + c.r + ',' + c.g + ',' + c.b + ',' + opacity + ')'
}

/**
 * Get route data
 */

function getRouteData (route, multiplier, index) {
  var data = {
    segments: routeSummarySegments(route, {
      inline: true
    }),
    index: index,
    time: route.average(),
    frequency: 0,
    cost: route.cost(),
    walkDistance: route.walkDistances(),
    calories: route.totalCalories(),
    productiveTime: route.timeInTransit(),
    emissions: route.emissions(),
    score: route.score(),
    rank: 0
  }

  if (multiplier > 1) {
    ['cost', 'calories', 'productiveTime', 'emissions'].forEach(function (type) {
      data[type] = data[type] * multiplier
    })
  }

  return data
}