client/plan/index.js
var Batch = require('batch')
var model = require('component-model')
var qs = require('component-querystring')
const typ = require('component-type')
const ll = require('@conveyal/lonlat')
var debounce = require('debounce')
var defaults = require('../components/segmentio/model-defaults/0.2.0')
var config = require('../config')
var convert = require('../convert')
var geocode = require('../geocode')
var Journey = require('../journey')
var loadPlan = require('./load')
var Location = require('../location')
var log = require('../log')('plan')
var ProfileScorer = require('otp-profile-score')
var ProfileQuery = require('../profile-query')
var store = require('./store')
var updateRoutes = require('./update-routes')
/**
* Debounce updates to once every 50ms
*/
var DEBOUNCE_UPDATES = 25
var LIMIT = 2
/**
* Expose `Plan`
*/
var Plan = module.exports = model('Plan')
.use(defaults({
bike: true,
bikeShare: true,
bikeSpeed: 8,
bikeTrafficStress: 4,
bus: true,
car: true,
carParkingCost: 10,
carCostPerMile: 0.56,
days: 'M—F',
end_time: 9,
from: '',
from_valid: false,
loading: true,
matches: [],
maxBikeTime: 20,
maxWalkTime: 15,
options: [],
query: new ProfileQuery(),
scorer: new ProfileScorer(),
start_time: 7,
to: '',
to_valid: false,
train: true,
tripsPerYear: 235,
walk: true,
walkSpeed: 3
}))
.attr('bike')
.attr('bikeShare')
.attr('bikeSpeed')
.attr('bikeTrafficStress')
.attr('bus')
.attr('car')
.attr('carParkingCost')
.attr('carCostPerMile')
.attr('days')
.attr('end_time')
.attr('from')
.attr('from_id')
.attr('from_ll')
.attr('from_valid')
.attr('loading')
.attr('matches')
.attr('maxBikeTime')
.attr('maxWalkTime')
.attr('journey')
.attr('options')
.attr('query')
.attr('scorer')
.attr('start_time')
.attr('to')
.attr('to_id')
.attr('to_ll')
.attr('to_valid')
.attr('train')
.attr('tripsPerYear')
.attr('walk')
.attr('walkSpeed')
/**
* Expose `load`
*/
module.exports.load = function (userOpts) {
return loadPlan(Plan, userOpts)
}
/**
* Sync plans with localStorage
*/
Plan.on('change', function (plan, name, val) {
log('plan.%s changed to %s', name, val)
if (name === 'bikeSpeed') {
plan.scorer().rates.bikeSpeed = convert.mphToMps(val)
} else if (name === 'walkSpeed') {
plan.scorer().rates.walkSpeed = convert.mphToMps(val)
}
// Store in localStorage & track the change
if (name !== 'options' && name !== 'journey' && name !== 'loading') plan.store()
})
/**
* Keep start/end times in sync
*/
Plan.on('change start_time', function (plan, val, prev) {
if (val >= plan.end_time()) plan.end_time(val + 1)
})
Plan.on('change end_time', function (plan, val, prev) {
if (val <= plan.start_time()) plan.start_time(val - 1)
})
/**
* Update routes. Restrict to once every 25ms.
*/
Plan.prototype.updateRoutes = debounce(function (opts, callback) {
updateRoutes(this, opts, callback)
}, DEBOUNCE_UPDATES)
/**
* Geocode
*/
Plan.prototype.geocode = function (dest, callback) {
if (!callback) callback = function () {}
var plan = this
var address = plan[dest]()
var ll = plan[dest + '_ll']()
if (address && address.length > 0) {
geocode(address, function (err, ll) {
if (err) {
callback(err)
} else {
plan[dest + '_ll'](ll)
callback(null, ll)
}
})
} else {
callback(null, ll)
}
}
/**
* Save Journey
* Evan Siroky note - 2017-06-30: I don't think this gets called from anywhere
*/
Plan.prototype.saveJourney = function (callback) {
var opts = {}
var skipKeys = ['options', 'journey', 'scorer']
for (var key in this.attrs) {
if (skipKeys.indexOf(key) !== -1 || key.indexOf('to') === 0 || key.indexOf('from') === 0) {
continue
}
opts[key] = this.attrs[key]
}
// Create new journey
var journey = new Journey({
locations: [{
_id: this.from_id()
}, {
_id: this.to_id()
}],
opts: opts
})
// Save
journey.save(callback)
}
/**
* Valid coordinates
*/
Plan.prototype.validCoordinates = function () {
return this.coordinateIsValid(this.from_ll()) && this.coordinateIsValid(this.to_ll())
}
/**
* Set Address
*/
Plan.prototype.setAddress = function (name, locationData, callback) {
callback = callback || function () {} // noop callback
if (
!locationData &&
(
typ(locationData) !== 'string' ||
!locationData.address ||
typ(locationData.lat) !== 'number'
)
) {
return callback()
}
var location = new Location()
var plan = this
const address = typ(locationData) === 'string'
? locationData
: (locationData.address || ll.toString(locationData))
var c = address.split(',')
var isCoordinate = c.length === 2 && !isNaN(parseFloat(c[0])) && !isNaN(parseFloat(c[1]))
if (isCoordinate) {
location.coordinate({
lat: parseFloat(c[1]),
lng: parseFloat(c[0])
})
}
if (address) {
location.address(address)
}
if (config.geocode().save_to_db) {
// code that will save each geocode to a db
location.save(function (err, res) {
if (err) {
callback(err)
} else {
var changes = {}
if (isCoordinate) {
changes[name] = res.body.address
if (res.body.city) changes[name] += ', ' + res.body.city
if (res.body.state) changes[name] += ', ' + res.body.state
} else {
changes[name] = address
}
changes[name + '_ll'] = res.body.coordinate
changes[name + '_id'] = res.body._id
changes[name + '_valid'] = true
plan.set(changes)
callback(null, res.body)
}
})
} else {
// code that doesn't save geocode to a db
const changes = {}
// determine if reverse geocoding, regular geocoding is needed
if (isCoordinate) {
// do reverse geocode
geocode.reverse(c, (err, res) => {
if (err) {
return callback(err)
} else {
changes[name] = res.address
if (res.city) changes[name] += ', ' + res.city
if (res.state) changes[name] += ', ' + res.state
changes[name + '_ll'] = location.coordinate()
changes[name + '_valid'] = true
plan.set(changes)
callback(null, location.toJSON())
}
})
} else if (typ(locationData.magicKey) === 'string') {
// received an autcomplete suggestion, do geocode to get details
geocode(address, locationData.magicKey, (err, res) => {
if (err) {
return callback(err)
} else {
location.coordinate(res)
changes[name] = address
changes[name + '_ll'] = res
changes[name + '_valid'] = true
plan.set(changes)
callback(null, location.toJSON())
}
})
} else if (typ(locationData.lat) !== 'number') {
// do regular geocode
geocode(locationData, (err, res) => {
if (err) {
return callback(err)
} else {
location.coordinate(res)
changes[name] = locationData
changes[name + '_ll'] = res
changes[name + '_valid'] = true
plan.set(changes)
callback(null, location.toJSON())
}
})
} else {
changes[name] = address
const coords = ll(locationData)
changes[name + '_ll'] = {
lat: coords.lat,
lng: coords.lon
}
changes[name + '_valid'] = true
plan.set(changes)
callback(null, location.toJSON())
}
}
}
/**
* Set both addresses
*/
Plan.prototype.setAddresses = function (from, to, callback) {
// Initialize the default locations
var plan = this
Batch()
.push(function (done) {
plan.setAddress('from', from, done)
})
.push(function (done) {
plan.setAddress('to', to, done)
}).end(callback)
}
/**
* Rescore Options
*/
Plan.prototype.rescoreOptions = function () {
var scorer = this.scorer()
var options = this.options()
options.forEach(function (o) {
o.rescore(scorer)
})
this.store()
}
Plan.prototype.coordinateIsValid = function (c) {
return !!c && !!parseFloat(c.lat) && !!parseFloat(c.lng)
}
/**
* Modes as a CSV
*/
Plan.prototype.modesCSV = function () {
var modes = []
if (this.bike()) modes.push('BICYCLE')
if (this.bikeShare()) modes.push('BICYCLE_RENT')
if (this.bus()) modes.push('BUS')
if (this.train()) modes.push('TRAINISH')
if (this.walk()) modes.push('WALK')
if (this.car()) modes.push('CAR')
return modes.join(',')
}
/**
* Set modes from string
*/
Plan.prototype.setModes = function (csv) {
if (!csv || csv.length < 1) return
var modes = csv.split ? csv.split(',') : csv
this.bike(modes.indexOf('BICYCLE') !== -1)
this.bikeShare(modes.indexOf('BICYCLE_RENT') !== -1)
this.bus(modes.indexOf('BUS') !== -1)
this.train(modes.indexOf('TRAINISH') !== -1)
this.car(modes.indexOf('CAR') !== -1)
}
/**
* Generate Query Parameters for this plan
*/
Plan.prototype.generateQuery = function () {
var from = this.from_ll() || {}
var to = this.to_ll() || {}
// Transit modes
var accessModes = ['WALK']
var directModes = [] // ['CAR', 'WALK']
var egressModes = ['WALK']
var transitModes = []
if (this.walk()) {
directModes.push('WALK')
}
if (this.bike()) {
accessModes.push('BICYCLE')
directModes.push('BICYCLE')
}
if (this.bikeShare()) {
accessModes.push('BICYCLE_RENT')
directModes.push('BICYCLE_RENT')
egressModes.push('BICYCLE_RENT')
}
if (this.bus()) transitModes.push('BUS')
if (this.car()) {
accessModes.push('CAR_PARK')
directModes.push('CAR')
}
if (this.train()) transitModes.push('TRAINISH')
var startTime = this.start_time()
var endTime = this.end_time()
// Convert the hours into strings
startTime += ':00'
endTime = endTime === 24 ? '23:59' : endTime + ':00'
return {
accessModes: accessModes.join(','),
bikeSafe: 1000,
bikeSpeed: convert.mphToMps(this.bikeSpeed()),
bikeTrafficStress: this.bikeTrafficStress(),
date: this.nextDate(),
directModes: directModes.join(','),
egressModes: egressModes.join(','),
endTime: endTime,
from: {
lat: from.lat,
lon: from.lng,
name: this.from()
},
maxBikeTime: this.maxBikeTime(),
maxWalkTime: this.maxWalkTime(),
maxCarTime: 45,
startTime: startTime,
to: {
lat: to.lat,
lon: to.lng,
name: this.to()
},
limit: LIMIT,
transitModes: transitModes.join(','),
walkSpeed: convert.mphToMps(this.walkSpeed())
}
}
Plan.prototype.generateOtpQuery = function () {
var query = this.generateQuery()
query.from = query.from.lat + ',' + query.from.lon
query.to = query.to.lat + ',' + query.to.lon
return query
}
Plan.prototype.generateURL = function () {
return config.base_url() + config.api_url() + '/plan?' + decodeURIComponent(qs.stringify(this.generateOtpQuery()))
}
/**
* Store in localStorage. Restrict this I/O to once every 25ms.
*/
Plan.prototype.store = debounce(function () {
store(this)
}, DEBOUNCE_UPDATES)
/**
* Clear localStorage
*/
Plan.prototype.clearStore = store.clear
/**
* Save URL
*/
Plan.prototype.saveURL = function () {
window.history.replaceState(null, '', '/planner?' + this.generateQueryString())
}
/**
* Get next date for day of the week
*/
Plan.prototype.nextDate = function () {
var now = new Date()
var date = now.getDate()
var dayOfTheWeek = now.getDay()
switch (this.days()) {
case 'M—F':
if (dayOfTheWeek === 0) now.setDate(date + 1)
if (dayOfTheWeek === 6) now.setDate(date + 2)
break
case 'Sat':
now.setDate(date + (6 - dayOfTheWeek))
break
case 'Sun':
now.setDate(date + (7 - dayOfTheWeek))
break
}
return now.toISOString().split('T')[0]
}
/**
* Generate `places` for transitive
*/
Plan.prototype.generatePlaces = function () {
var fll = this.from_ll()
var tll = this.to_ll()
var places = []
if (fll) {
places.push({
place_id: 'from',
place_lat: fll.lat,
place_lon: fll.lng,
place_name: 'From'
})
}
if (tll) {
places.push({
place_id: 'to',
place_lat: tll.lat,
place_lon: tll.lng,
place_name: 'To'
})
}
return places
}
/**
* Generate QueryString
*/
Plan.prototype.generateQueryString = function () {
return qs.stringify({
from: this.from(),
to: this.to(),
modes: this.modesCSV(),
start_time: this.start_time(),
end_time: this.end_time(),
days: this.days()
})
}
/**
* Clear
*/
Plan.prototype.clear = function () {
this.set({
options: [],
journey: {
places: this.generatePlaces()
}
})
}