muniNow aims to be a nicer NextMuni by giving users the ability to see when the soonest Muni bus is arriving at a glance.
Powered by D3, built at Catalyst/Hack Reactor as a personal project. And many thanks to Mike and Adnan.
al lin, aug. 2013
muniNow aims to be a nicer NextMuni by giving users the ability to see when the soonest Muni bus is arriving at a glance.
Powered by D3, built at Catalyst/Hack Reactor as a personal project. And many thanks to Mike and Adnan.
al lin, aug. 2013
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8"> | |
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> | |
<title>muniNow bl.ocks demo</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.0-rc1/css/bootstrap.min.css" media="screen"> | |
<script src="http://code.jquery.com/jquery-2.0.3.min.js"></script> | |
<script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.1/underscore-min.js"></script> | |
<script src="http://cdnjs.cloudflare.com/ajax/libs/handlebars.js/1.0.0/handlebars.min.js"></script> | |
<script src="http://cdnjs.cloudflare.com/ajax/libs/d3/3.2.2/d3.v3.min.js"></script> | |
<script src="http://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.0-rc1/js/bootstrap.min.js"></script> | |
<style type='text/css'> | |
#svg-muninow { | |
border: 1px solid rgba(153,153,153, 0.5); | |
width: 100%; | |
min-height: 500px; | |
} | |
.center-time { | |
font-family: helvetica, sans-serif; | |
font-weight: bold; | |
fill: rgba(112,158,192, 1); | |
stroke: rgb(102,102,102, 0.75); | |
stroke-width: 2px; | |
} | |
.arc-path, .dest-path { | |
stroke: rgba(153, 153, 153, 0.10); | |
stroke-width: 2px; | |
cursor: pointer; | |
} | |
.highlighted { | |
fill: #fc7d47; | |
} | |
.highlighted2 { | |
fill: #deae8a; | |
} | |
.transition { | |
fill: #deae8a; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<h1>muniNow</h1> | |
<div class="row"> | |
<form id="RouteForm" class="form-horizontal"> | |
<p> | |
<select id="RouteSelector" class="form-control input-sm"> | |
<option value="-1">Please select your Muni line..</option> | |
</select> | |
</p> | |
<p> | |
<select id="DirectionSelector" class="form-control input-sm"></select> | |
</p> | |
<p> | |
<label for="StopSelector">From:</label><select id="StopSelector" class="form-control input-sm"></select> | |
</p> | |
<p> | |
<label for="DestSelector">To:</label><select id="DestSelector" class="form-control input-sm"></select> | |
</p> | |
<button type="button" class="btn btn-info btn-mini" id="AddRouteButton">Add Route</button> | |
</form> | |
</div> | |
<div id="AdditionalInfo" class="row"><div id="UrlDiv"></div></div> | |
<div id="ChartArea" style="max-height: 600"></div> | |
</div> | |
<script src="muninow-api.js"></script> | |
<script src="muninow-forms.js"></script> | |
<script src="muninow-d3.js"></script> | |
<script src="muninow-init.js"></script> </body> | |
</html> |
var nextBusser = function(agency) { | |
var nb = { | |
routesInfo: {}, | |
// basic nextbus query, check the Nextbus PDF for commands & options | |
getNextbus: function(query, callback) { | |
var apiUrl = 'http://webservices.nextbus.com/service/publicXMLFeed'; | |
$.get(apiUrl, query, function(xml){ | |
callback.call(this, xml); | |
}); | |
}, | |
// needed a custom query function since the query URL reuses the "stops" key | |
// stopsArray is an array of stop objects in the format: {r: route tag, s: stop tag} | |
getMultiStops: function(stopsArray, destsArray, callback) { | |
var baseQuery = 'http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=sf-muni'; | |
var args = Array.prototype.slice.call(arguments); | |
if(_.isArray(args[1]) && _.isFunction(args[2])) { | |
destsArray = args[1]; | |
callback = args[2]; | |
} else if(_.isFunction(args[1])) { | |
destsArray = undefined; | |
callback = args[1]; | |
} | |
var query = nb._buildQuery(baseQuery, stopsArray, destsArray); | |
$.get(query, function(xml){ | |
callback.call(this, xml); | |
}); | |
}, | |
// combines all the stops to form a big query -- does not check for duplicate stops | |
// stopsArray & destsArray are array of stop objects in the format: {r: route tag, s: stop tag} | |
_buildQuery: function(baseQuery, stopsArray, destsArray) { | |
for(var i=0; i<stopsArray.length; i++) { | |
baseQuery += '&stops='+ stopsArray[i].r +'|'+ stopsArray[i].s; | |
if(destsArray && typeof destsArray[i] !== 'undefined') { baseQuery += '&stops='+ destsArray[i].r +'|'+ destsArray[i].s; } | |
} | |
return baseQuery; | |
}, | |
parseXMLroutes: function(xml, callback) { | |
var routes = []; | |
var route = {}; | |
$(xml).find("body > route").each(function(indx, rt){ | |
$rt = $(rt); | |
route = { | |
routeTag: $rt.attr('tag'), | |
routeTitle: $rt.attr('title') | |
}; | |
routes.push(route); | |
}); | |
return callback ? callback(routes) : routes; | |
}, | |
// converting raw xml: http://webservices.nextbus.com/service/publicXMLFeed?command=routeConfig&a=sf-muni&r=J | |
// into two objects, then passing them to a callback or as an object | |
parseXMLstops: function(xml, callback) { | |
var directions = {}; | |
var $dir, dirTag; | |
$(xml).find('direction').each(function(indx, dir){ | |
var $dir = $(dir); | |
dirTag = $dir.attr('tag'); | |
directions[dirTag] = { | |
title : $dir.attr('title'), | |
name : $dir.attr('name'), | |
dirTag: dirTag, | |
stops : [] | |
}; | |
$dir.find('stop').each(function(indx, stop) { | |
directions[dirTag].stops.push($(stop).attr('tag')); | |
}); | |
}); | |
var $route = $(xml).find("body > route"); | |
var stopsInfo = { | |
routeTag: $route.attr('tag'), | |
title: $route.attr('title'), | |
color: $route.attr('color'), | |
oppositeColor: $route.attr('oppositeColor') | |
}; | |
var $stop, stopTag; | |
$(xml).find("body route > stop").each(function(indx, stop) { | |
$stop = $(stop); | |
stopTag = $stop.attr('tag'); | |
stopsInfo[stopTag] = { | |
title : $stop.attr('title'), | |
lat : $stop.attr('lat'), | |
lon : $stop.attr('lon'), | |
stopId : $stop.attr('stopId') | |
}; | |
}); | |
return callback ? callback(stopsInfo, directions) : {stopsInfo:stopsInfo, directions:directions}; | |
}, | |
// parses prediction XML for single stops: | |
// http://webservices.nextbus.com/service/publicXMLFeed?command=predictions&a=sf-muni&r=5&s=5684 | |
parseXMLtimes: function(xml, callback) { | |
var predictions = []; | |
var routeTag = $(xml).find('predictions').attr('routeTag'); | |
var $pr, prediction; | |
$(xml).find('prediction').each(function(indx, pr) { | |
$pr = $(pr); | |
prediction = { | |
routeTag: routeTag, | |
seconds: $pr.attr('seconds'), | |
vehicle: $pr.attr('vehicle'), | |
dirTag: $pr.attr('dirTag') | |
}; | |
predictions.push(prediction); | |
}); | |
return callback ? callback(predictions) : predictions; | |
}, | |
// parses predictionsForMultiStops: | |
// http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=sf-muni&stops=5|5684&stops=38|5684&stops=38|5689 | |
// replaced by hashXMLmulti & combinePredictions to make total trip predictions. | |
// still works fine for parsing multiple stops, but won't discern between stop and destination stop | |
parseXMLmulti: function(xml, callback) { | |
var predictions = []; | |
var $stop, $pr; | |
var routeTag, stopTag, prediction; | |
$(xml).find('predictions').each(function(indx, prs) { | |
$stop = $(prs); | |
routeTag = $stop.attr('routeTag'); | |
stopTag = $stop.attr('stopTag'); | |
$stop.find('prediction').each(function(indx, pr) { | |
$pr = $(pr); | |
prediction = { | |
routeTag: routeTag, | |
stopTag: stopTag, | |
seconds: $pr.attr('seconds'), | |
vehicle: $pr.attr('vehicle') | |
}; | |
predictions.push(prediction); | |
}); | |
}); | |
return callback ? callback(predictions) : predictions; | |
}, | |
// groups the stop and destination times by vehicle number in orer to make predictions based | |
// on the difference between two stops. late at night, nextbus might return the vehicle's | |
// second departure time after its roundtrip run (e.g. arriving here, going to the end, | |
// arriving here again), so seconds are stored as arrays. | |
// ** needs to work with combinePredictions to return a similar result as parseXMLmulti ** | |
/* | |
Hash Hierarchy = { | |
routeTag: { | |
vehicle: { | |
stop: [seconds, seconds] | |
} | |
} | |
} | |
*/ | |
hashXMLmulti: function(xml, callback) { | |
var predictions = {}; | |
var routeTag, stopTag, vehicle; | |
var $stop, $pr; | |
$(xml).find('predictions').each(function(indx, prs){ | |
$stop = $(prs); | |
routeTag = $stop.attr('routeTag'); | |
stopTag = $stop.attr('stopTag'); | |
if(typeof predictions[routeTag] === 'undefined') { predictions[routeTag] = {}; } | |
$stop.find('prediction').each(function(indx, pr){ | |
$pr = $(pr); | |
vehicle = $pr.attr('vehicle'); | |
if(typeof predictions[routeTag][vehicle] === 'undefined') { predictions[routeTag][vehicle] = {}; } | |
if(predictions[routeTag][vehicle][stopTag]) { predictions[routeTag][vehicle][stopTag].push($pr.attr('seconds')); } | |
if(typeof predictions[routeTag][vehicle][stopTag] === 'undefined') { predictions[routeTag][vehicle][stopTag] = [$pr.attr('seconds')]; } | |
}); | |
}); | |
return callback ? callback(predictions) : predictions; | |
}, | |
// combines predictions to estimate the time to a destination by taking the difference | |
// between two stops for a particular vehicle. in conjuction with hashXMLmulti, | |
// will return a result similar to parseXMLmulti, with added 'to destination' times | |
// relies on a predictions hash created by hashXMLmulti. stopQueries and destQueries | |
// are used to determine which stops are stops vs. destinations. | |
// there are many loops here; at 4am, they seem necessary.. | |
// most loops will be very short: stopQueries is based on the number of routes a user | |
// wants to track (probably a max of 5) and stopTimes and destTimes will most likely | |
// be of length 1. edge cases are the all-nighter routes like the 5-Fulton at 3am, | |
// then the array is of length 2. | |
combinePredictions: function(prsHash, stopQueries, destQueries) { | |
var predictions = []; | |
var query, stopTimes, destTimes; | |
var pre; | |
var i, j, k, vehicle; | |
// sub-routine used to make prediction objects to be pushed into the predictions array | |
function mkPrediction(routeTag, vehicle, timeInfo) { | |
var prediction = { | |
routeTag: routeTag, | |
vehicle: vehicle | |
}; | |
_.extend(prediction, timeInfo); | |
return prediction; | |
} | |
for(i=0; i<stopQueries.length; i++) { | |
query = stopQueries[i]; | |
for(vehicle in prsHash[query.r]) { | |
if(prsHash[query.r][vehicle][query.s]) { | |
stopTimes = prsHash[query.r][vehicle][query.s]; | |
for(j=0; j<stopTimes.length; j++) { | |
pre = mkPrediction(query.r, vehicle, { | |
stopTag: query.s, | |
seconds: parseInt(stopTimes[j], 10) | |
}); | |
if(destQueries[i] && prsHash[query.r][vehicle][destQueries[i].s]) { | |
destTimes = prsHash[query.r][vehicle][destQueries[i].s]; | |
for(k=j; k<destTimes.length; k++) { | |
if(parseInt(destTimes[k], 10) > parseInt(pre.seconds, 10)) { | |
pre.destTag = destQueries[i].s; | |
pre.secondsTotal = parseInt(destTimes[k], 10); | |
pre.destSeconds = pre.secondsTotal - pre.seconds; | |
break; | |
} | |
} | |
} | |
predictions.push(pre); | |
} | |
} else if(destQueries[i] && prsHash[query.r][vehicle][destQueries[i].s]) { | |
destTimes = prsHash[query.r][vehicle][destQueries[i].s]; | |
for(k=0; k<destTimes.length; k++) { | |
var destInfo = { | |
destTag: destQueries[i].s, | |
destSeconds: parseInt(destTimes[k], 10), | |
seconds: -1 | |
}; | |
pre = mkPrediction(destQueries[i].r, vehicle, destInfo); | |
predictions.push(pre); | |
} | |
} | |
} | |
} | |
return predictions; | |
}, | |
init: function(agency, callback) { | |
var query = { | |
command:'routeList', | |
a: agency | |
}; | |
nb.getNextbus(query, function(xml){ | |
var routes = nb.parseXMLroutes(xml); | |
_(routes).each(function(rt){ | |
nb.routesInfo[rt.routeTag] = {}; | |
nb.routesInfo[rt.routeTag].title = rt.routeTitle; | |
}); | |
return callback ? callback(routes) : routes; | |
}); | |
} | |
}; | |
if(typeof agency === 'string') { | |
nb.init(agency, function(){ | |
return nb; | |
}); | |
} | |
return nb; | |
}; |
// returns a function that stores stops & routes to be used for a multiple stop query | |
var queryStorageMaker = function() { | |
var memo = {}; | |
return function(stop, route, del) { | |
if( stop && route ) { | |
var query = { | |
r: route, | |
s: stop | |
}; | |
if(checkQuery(query)) { | |
if(del === 'true') { delete memo[route]; } | |
else { | |
memo[route] = query; | |
} | |
} | |
} | |
var queries = []; | |
for(var key in memo) { | |
queries.push(memo[key]); | |
} | |
return queries; | |
}; | |
}; | |
// converts the queries array to a serialised parameter for bookmarking | |
var serialiseQueries = function(queries) { | |
var params = {}; | |
for(var i=0; i<queries.length; i++) { | |
params[i] = queries[i]; | |
} | |
return $.param(params); | |
}; | |
// deserialised the parameter and checks them, returning an array of sanitised queries | |
var deserialiseParams = function(params) { | |
var deserialised = $.deparam(params); | |
var i = 0, queries = []; | |
while(deserialised[i]) { | |
if(checkQuery(deserialised[i])) { queries.push(deserialised[i]); } | |
i++; | |
} | |
return queries; | |
}; | |
// checks to see if the query has a valid bus line and a stop number of four digits | |
// four digits does *NOT* mean the stop number is necessarily valid | |
var checkQuery = function(pre) { | |
var regex = /\b\d{4}\b/; | |
return (typeof nb.routesInfo[pre.r] === 'undefined' || !regex.test(pre.s)) ? false : true; | |
}; | |
var makeChart = function(stopTag, routeTag) { | |
var chart = {}; | |
updateChart(stopTag, routeTag, chart); | |
return chart; | |
}; | |
var makeChartView = function(chart) { | |
$chartArea = $("#ChartArea"); | |
$chartArea.html('<h3 class="route-title"></h3>'); | |
$chartArea.append('<div class="chart-div"></div>'); | |
chart.div = $chartArea.children().last(); | |
chart.d3vis = d3.select(chart.div[0]).append("svg:svg").attr('id', 'svg-muninow'); | |
updateChartView(chart); | |
}; | |
var _queriesToStop = queryStorageMaker(); | |
var _queriesToDest = queryStorageMaker(); | |
var updateChart = function(stopTag, routeTag, chart) { | |
var destTag = $("#DestSelector").val(); | |
chart.stopQueries = _queriesToStop(stopTag, routeTag); | |
chart.destQueries = _queriesToDest(destTag, routeTag); | |
return chart; | |
}; | |
var updateFormView = function(stopTag, routeTag, callback) { | |
var $routeSel = $("#RouteSelector"); | |
$routeSel.val(routeTag); | |
var dirTag; | |
stopTag += ''; // stopTag should be a string, but if it isn't, convert it | |
_(nb.routesInfo[routeTag].directions).each(function(dir){ | |
for(var i=0; i<dir.stops.length; i++) { | |
if(dir.stops[i] === stopTag) { | |
dirTag = dir.dirTag; | |
} | |
} | |
}); | |
displayDirections(nb.routesInfo[routeTag].stopsInfo, nb.routesInfo[routeTag].directions, dirTag); | |
var $dirSel = $("#DirectionSelector"); | |
$dirSel.val(dirTag); | |
$dirSel.change(); | |
var $stopSel = $("#StopSelector"); | |
$stopSel.val(stopTag); | |
$stopSel.change(); | |
if(callback) { callback(stopTag, routeTag); } | |
}; | |
// helper function for views | |
var combineTitles = function(queries) { | |
var title = ''; | |
for(var i=0; i<queries.length; i++) { | |
if(i > 0) { | |
title += ' & '; | |
} | |
title += nb.routesInfo[queries[i].r].title; | |
} | |
return title; | |
}; | |
// helper function for views | |
var updateTitle = function(title) { | |
return $("#ChartArea .route-title").text(title); | |
}; | |
// helper function for views | |
var getSixSoonest = function(times) { | |
var newtimes = _.sortBy(times, function(time){ | |
return parseInt(time.seconds, 10); | |
}); | |
if(newtimes.length > 5) { | |
newtimes = newtimes.splice(0,5); | |
} | |
return newtimes; | |
}; | |
// helper function for views | |
var sortAndRender = function(predictions, vis) { | |
var times = getSixSoonest(predictions); | |
d3methods.render(times, vis); | |
}; | |
var updateChartView = function(chart) { | |
if(chart.timer) { window.clearInterval(chart.timer); } | |
$(chart.d3vis[0]).empty(); | |
(chart.d3vis).append("svg:g").attr("class", 'center-group'); | |
var stopQueries = chart.stopQueries; | |
destQueries = chart.destQueries; | |
var bookmarkableUrl = window.location.href.split('?')[0] + '?' + serialiseQueries(chart.stopQueries); | |
var info = Handlebars.compile('<a class="bookmarkable" href="{{url}}">Bookmarkable URL</a> - Additional Muni lines may be tracked by re-using the form above.'); | |
updateTitle(combineTitles(stopQueries)); | |
$("#AdditionalInfo").html(info({ url: bookmarkableUrl })); | |
var combined; | |
nb.getMultiStops(stopQueries, destQueries, function(xml){ | |
combined = nb.combinePredictions(nb.hashXMLmulti(xml), stopQueries, destQueries); | |
sortAndRender(combined, chart.d3vis); | |
setTimeout(function(){ | |
d3methods.ripple(chart.d3vis); | |
}, 500); | |
}); | |
chart.timer = setInterval(function(){ | |
nb.getMultiStops(chart.stopQueries, chart.destQueries, function(xml){ | |
combined = nb.combinePredictions(nb.hashXMLmulti(xml), stopQueries, destQueries); | |
sortAndRender(combined, chart.d3vis); | |
}); | |
}, 14500); | |
}; | |
var d3methods = { | |
_highlightColor: '#fc7d47', | |
_transitionColor: '#deae8a', | |
_highlight2Color: 'rgb(250,174,135)', | |
_toMin: function(sec) { | |
var fuzzy = 5*( 5 - sec/60 ); | |
return ( sec%60 > fuzzy ) ? Math.ceil(sec/60) : Math.floor(sec/60); | |
}, | |
_secToRadians: function(sec) { | |
return Math.round(parseFloat((sec/60)*6 * (Math.PI/180)) * 10000)/10000; | |
}, | |
_arcScaleMaker: function(max) { | |
return d3.scale.linear() | |
.domain([0, max]) | |
.range(["rgb(185,218,197)", "rgb(233,237,220)"]); | |
}, | |
_destScaleMaker: function(max) { | |
return d3.scale.linear() | |
.domain([0, max]) | |
.range(["rgb(243,231,214)", "rgb(230,230,213)"]); | |
}, | |
_resetColors: function(selector) { | |
d3.selectAll(selector).classed("highlighted", function(d, i){ | |
return false; | |
}); | |
d3.selectAll(selector).classed("highlighted2", function(d, i){ | |
return false; | |
}); | |
}, | |
ripple: function(vis) { | |
var d3arcs = vis.selectAll("path.arc-path"); | |
var d3centerText = vis.selectAll("text.center-time"); | |
var lastIndex = d3arcs[0].length-1; | |
var maxSeconds = parseInt(d3.select(d3arcs[0][lastIndex]).datum().seconds, 10); | |
var arcColorScale = d3methods._arcScaleMaker(maxSeconds); | |
var highlightColor = d3methods._highlightColor; | |
var transitionColor = d3methods._transitionColor; | |
d3arcs.transition() | |
.delay(function(d, i){ | |
return i*400; | |
}) | |
.duration(800) | |
.attr("fill", transitionColor) | |
.each("start", function(d, i){ | |
if(i === 1) { d3methods._resetColors("path.arc-path"); } | |
d3centerText.transition() | |
.delay(440) | |
.text(d3methods._toMin(d.seconds)); | |
}) | |
.each("end", function(d, i) { | |
var indx = i; | |
d3.select(this).transition() | |
.duration(350) | |
.attr("fill", arcColorScale(d.seconds)) | |
.each("end", function(d, i) { | |
if(indx === lastIndex) { | |
d3centerText.transition() | |
.delay(100) | |
.text(d3methods._toMin(d3centerText.datum().seconds)); | |
d3.select(d3arcs[0][0]).transition() | |
.duration(300) | |
.attr("fill", highlightColor) | |
.each("end", function(d, i) { | |
var d3this = d3.select(this); | |
d3this.classed("highlighted", true); | |
d3this.attr("fill", arcColorScale(d.seconds)); | |
}); | |
} | |
}); | |
}); | |
}, | |
render: function(dataset, vis) { | |
dataset = _.filter(dataset, function(prediction){ | |
return (prediction.seconds > 0) ? 1 : 0; | |
}); | |
// constants | |
var w = $(vis[0]).width(); | |
var h = $(vis[0]).height(); | |
h = (h < w) ? h : w; | |
var cX = Math.round(w/2); | |
var cY = Math.floor(h/2); | |
var arcMin = Math.floor(h*0.15); | |
var arcWidth = Math.floor(arcMin/3.75); | |
var arcPad = Math.ceil(arcWidth*0.1); | |
var highlightColor = d3methods._highlightColor; | |
var highlight2Color = d3methods._highlight2Color; | |
var transitionColor = d3methods._transitionColor; | |
var destColor = d3methods._destColor; | |
var arcColorScale = d3methods._arcScaleMaker( d3.max(dataset, function(d) { | |
return parseInt(d.seconds, 10); | |
}) ); | |
var destColorScale = d3methods._destScaleMaker( d3.max(dataset, function(d) { | |
return parseInt(d.secondsTotal, 10); | |
}) ); | |
function updateCenter(newData) { | |
d3centerText.data(newData).text(function(d){ | |
return d3methods._toMin(d.seconds); | |
}) | |
.style("font-size", Math.floor(arcMin*1.44) + 'px') | |
.attr("transform", 'translate(0,'+ parseInt(arcMin/2, 10) +')'); | |
} | |
var transitionTime = 3000; | |
// main group where objects are located -- saves from declaring multiple transforms | |
var g = vis.select("g.center-group"); | |
g.attr("transform", 'translate('+ cX +','+ cY +')'); | |
var gArc = g.selectAll("g.arc-group"); | |
var d3centerText = g.selectAll(".center-time"); | |
var key = function(d) { | |
return d.vehicle; | |
}; | |
gArc = gArc.data(dataset, key); | |
gArc.exit().remove(); | |
var centerTextData = [dataset[0]]; | |
// defining arc accessor | |
var r; | |
var arc = d3.svg.arc() | |
.innerRadius(function(d, i) { | |
r = d.index || i; | |
return arcMin + r*(arcWidth) + arcPad; | |
}) | |
.outerRadius(function(d, i) { | |
r = d.index || i; | |
return arcMin + (r+1)*(arcWidth); | |
}) | |
.startAngle(0) | |
.endAngle(function(d) { | |
return d3methods._secToRadians(d.seconds); | |
}); | |
var arcDest = d3.svg.arc() | |
.innerRadius(function(d, i) { | |
r = d.index || i; | |
return arcMin + r*(arcWidth) + arcPad; | |
}) | |
.outerRadius(function(d, i) { | |
r = d.index || i; | |
return arcMin + (r+1)*(arcWidth); | |
}) | |
.startAngle(function(d) { | |
if(d.seconds < 0) { | |
return 0; | |
} else { | |
var pad = ( parseInt(d.secondsTotal-d.seconds, 10) > 180 ) ? 15 : 8; | |
return d3methods._secToRadians(d.seconds + pad); | |
} | |
}) | |
.endAngle(function(d) { | |
return d3methods._secToRadians(d.secondsTotal); | |
}); | |
function arcTween(a, indx) { | |
var end = { | |
index: indx, | |
seconds: a.seconds | |
}; | |
var inter = d3.interpolateObject(this._current, end); | |
this._current = end; | |
return function(t) { | |
return arc(inter(t), indx); | |
}; | |
} | |
function destTween(a, indx) { | |
var end = { | |
index: indx, | |
seconds: a.seconds, | |
secondsTotal: a.secondsTotal | |
}; | |
var inter = d3.interpolateObject(this._current, end); | |
this._current = end; | |
return function(t) { | |
return arcDest(inter(t), indx); | |
}; | |
} | |
// update for arcs | |
// loop below is to see if there is a highlighted arc | |
var hasHighlight = false; | |
gArc.select("path.arc-path").each(function(d){ | |
if( this.classList.contains('highlighted') || this.classList.contains('highlighted2') ) { | |
hasHighlight = true; | |
centerTextData = [d3.select(this).datum()]; | |
} | |
}); | |
// re-colors the arcs, if there is no highlighted arc (from above), highlight the first one | |
gArc.select("path.arc-path").classed("highlighted", function(d, i){ | |
if( this.classList.contains("highlighted") || (!hasHighlight && i === 0)) { | |
return true; | |
} else { | |
return false; | |
} | |
}); | |
gArc.select("path.arc-path").transition() | |
.duration(transitionTime) | |
.attrTween("d", arcTween); | |
gArc.select("path.dest-path").transition() | |
.duration(transitionTime) | |
.attrTween("d", destTween); | |
// enter for arcs | |
var group = gArc.enter().append("svg:g").attr("class", 'arc-group'); | |
group.append("svg:path").attr("class", 'arc-path') | |
.attr("fill", function(d, i){ | |
return arcColorScale(d.seconds); | |
}) | |
.classed("highlighted", function(d, i){ | |
if(i === 0) { | |
return true; | |
} | |
}) | |
.attr("d", arc) | |
.each(function(d, i){ | |
this._current = { | |
index: i, | |
seconds: d.seconds | |
}; | |
if( d.secondsTotal && d.secondsTotal < 60*60 ) { | |
var indx = i; | |
d3.select(this.parentNode).append("svg:path").attr("class", 'dest-path') | |
.attr("fill", function(d){ | |
return destColorScale(d.secondsTotal); | |
}) | |
.attr("d", function(d, i) { | |
return arcDest(d, indx); | |
}) | |
.each(function(d){ | |
this._current = { | |
index: indx, | |
seconds: d.seconds, | |
secondsTotal: d.secondsTotal | |
}; | |
}); | |
} | |
}); | |
gArc.selectAll("path.arc-path") | |
.on("click", function(d, i){ | |
d3methods._resetColors("path.arc-path"); | |
d3methods._resetColors("path.dest-path"); | |
var d3selected = d3.select(this); | |
d3selected.classed("highlighted", true); | |
centerTextData = [d]; | |
var busTitle = nb.routesInfo[d.routeTag].title; | |
var stopTitle = nb.routesInfo[d.routeTag].stopsInfo[centerTextData[0].stopTag].title; | |
var minTitle = d3methods._toMin(d.seconds); | |
if(minTitle > 0) { | |
minTitle = ' in '+ minTitle + ' min'; | |
} else { | |
minTitle = ' in less than 1 min'; | |
} | |
updateTitle(stopTitle +': '+ busTitle + minTitle); | |
updateCenter(centerTextData); | |
}); | |
gArc.selectAll("path.dest-path") | |
.on("click", function(d, i){ | |
d3methods._resetColors("path.arc-path"); | |
d3methods._resetColors("path.dest-path"); | |
var d3selected = d3.select(this); | |
d3selected.classed("highlighted", true); | |
var d3stopArc = d3.select(this.previousElementSibling); | |
d3stopArc.classed("highlighted2", true); | |
centerTextData = [{seconds: d.secondsTotal}]; | |
// var busTitle = routesInfo.routesList[d.routeTag]; | |
var stopTitle = nb.routesInfo[d.routeTag].stopsInfo[d.stopTag].title; | |
var stop2Title = nb.routesInfo[d.routeTag].stopsInfo[d.destTag].title; | |
updateTitle(stopTitle + ' to ' + stop2Title); | |
updateCenter(centerTextData); | |
}); | |
// enter and update for the center text | |
d3centerText = d3centerText.data(centerTextData); | |
d3centerText.enter().append("svg:text") | |
.attr("class", "center-time") | |
.attr("text-anchor", 'middle'); | |
updateCenter(centerTextData); | |
if(!d3.selectAll(".click-circle")[0].length) { | |
g.append("circle").attr("r", arcMin*0.85).attr("fill", "rgba(255,255,255, 0.01)").attr("class", 'click-circle'); | |
} | |
g.select(".click-circle").on("click", function(d){ | |
this.__rotate__ = !this.__rotate__; | |
if(this.__rotate__) { | |
matchTime(); | |
} else { | |
resetZero(); | |
} | |
}); | |
} | |
}; | |
var matchTime = function() { | |
var dt = new Date(); | |
var deg = (dt.getMinutes()/60)*360; | |
d3.selectAll('g.arc-group').transition().duration(300).attr("transform", 'rotate(' + deg + ')'); | |
}; | |
var resetZero = function() { | |
d3.selectAll('g.arc-group').transition().duration(300).attr("transform", 'rotate(0)'); | |
}; |
// dirTag is optional. if provided, will set drop-down to the specified direction | |
var displayDirections = function(stopsInfo, routes, dirTag) { | |
var $dirSel = $("#DirectionSelector"); | |
$dirSel.empty(); | |
$("#stopSelector").empty(); | |
var routeOption = Handlebars.compile('<option value="{{value}}">{{title}}{{#if from}} from {{from}}{{/if}}</option>'); | |
var stopOption = Handlebars.compile('<option value="{{value}}">{{title}}</option>'); | |
var opt1 = ''; | |
_(routes).each(function(route, key) { | |
// if a route has more than two origins add a 'from' to clarify | |
if (route.name === 'Inbound' && Object.keys(routes).length>2) { | |
route.from = stopsInfo[route.stops[0]].title; | |
} | |
route.value = key; | |
$dirSel.append(routeOption(route)); | |
}); | |
dirTag ? $dirSel.val(dirTag) : dirTag = $dirSel.val(); | |
displayStops(stopsInfo, routes, dirTag); | |
}; | |
var stopOption = Handlebars.compile('<option value="{{value}}">{{title}}</option>'); | |
// stopTag is optional. if provided will set drop-down to specified stop | |
var displayStops = function(stopsInfo, routes, dirTag, stopTag) { | |
var $stopSel = $("#StopSelector"); | |
$stopSel.empty(); | |
_(routes[dirTag].stops).each(function(stopNum) { | |
$stopSel.append(stopOption({ | |
value: stopNum, | |
title: stopsInfo[stopNum].title | |
})); | |
}); | |
stopTag ? $stopSel.val(stopTag) : stopTag = $stopSel.val(); | |
displayDestinations(stopsInfo, routes, dirTag, stopTag); | |
}; | |
var displayDestinations = function(stopsInfo, routes, dirTag, selectedStop) { | |
var $destSel = $("#DestSelector"); | |
$destSel.empty(); | |
var stops = routes[dirTag].stops; | |
var flag = false; | |
_(stops).each(function(stopTag) { | |
if(flag) { | |
$destSel.append(stopOption({ | |
value: stopTag, | |
title: stopsInfo[stopTag].title | |
})); | |
} | |
if(stopTag === selectedStop) { flag = true; } | |
}); | |
}; |
// stores information of the routes the user has looked up for the session | |
var nb = nextBusser(); | |
$(function() { | |
var charts = []; | |
$("#RouteSelector").change(function(){ | |
var routeTag = $("#RouteSelector").val(); | |
// if the route hasn't been looked up before, look it up and store it in routesInfo | |
if(nb.routesInfo[routeTag] && typeof nb.routesInfo[routeTag].stopsInfo === 'undefined') { | |
nb.getNextbus({command: 'routeConfig', a:'sf-muni', r: routeTag}, function(xml) { | |
_.extend(nb.routesInfo[routeTag], nb.parseXMLstops(xml)); | |
displayDirections(nb.routesInfo[routeTag].stopsInfo, nb.routesInfo[routeTag].directions); | |
}); | |
} else { | |
displayDirections(nb.routesInfo[routeTag].stopsInfo, nb.routesInfo[routeTag].directions); | |
} | |
}); | |
$("#DirectionSelector").change(function(){ | |
var route = nb.routesInfo[$("#RouteSelector").val()]; | |
displayStops(route.stopsInfo, route.directions, $("#DirectionSelector").val()); | |
}); | |
$("#StopSelector").change(function(){ | |
var route = nb.routesInfo[$("#RouteSelector").val()]; | |
var stopTag = $("#StopSelector").val(); | |
displayDestinations(route.stopsInfo, route.directions, $("#DirectionSelector").val(), stopTag); | |
}); | |
$("#AddRouteButton").click(function(){ | |
var routeTag = $("#RouteSelector").val(); | |
var stopTag = $("#StopSelector").val(); | |
if(charts[0]) { | |
updateChart(stopTag, routeTag, charts[0]); | |
updateChartView(charts[0]); | |
} else { | |
charts.push(makeChart(stopTag, routeTag)); | |
makeChartView(charts[0]); | |
} | |
}); | |
// list pulled from: http://webservices.nextbus.com/service/publicXMLFeed?command=routeList&a=sf-muni | |
var routesToInsert = [ | |
["F", "F-Market & Wharves"], | |
["J", "J-Church"], | |
["KT", "KT-Ingleside/Third Street"], | |
["L", "L-Taraval"], | |
["M", "M-Ocean View"], | |
["N", "N-Judah"], | |
["NX", "NX-N Express"], | |
["1", "1-California"], | |
["1AX", "1AX-California A Express"], | |
["1BX", "1BX-California B Express"], | |
["2", "2-Clement"], | |
["3", "3-Jackson"], | |
["5", "5-Fulton"], | |
["6", "6-Parnassus"], | |
["8X", "8X-Bayshore Express"], | |
["8AX", "8AX-Bayshore A Express"], | |
["8BX", "8BX-Bayshore B Express"], | |
["9", "9-San Bruno"], | |
["9L", "9L-San Bruno Limited"], | |
["10", "10-Townsend"], | |
["12", "12-Folsom/Pacific"], | |
["14", "14-Mission"], | |
["14L", "14L-Mission Limited"], | |
["14X", "14X-Mission Express"], | |
["16X", "16X-Noriega Express"], | |
["17", "17-Parkmerced"], | |
["18", "18-46th Avenue"], | |
["19", "19-Polk"], | |
["21", "21-Hayes"], | |
["22", "22-Fillmore"], | |
["23", "23-Monterey"], | |
["24", "24-Divisadero"], | |
["27", "27-Bryant"], | |
["28", "28-19th Avenue"], | |
["28L", "28L-19th Avenue Limited"], | |
["29", "29-Sunset"], | |
["30", "30-Stockton"], | |
["30X", "30X-Marina Express"], | |
["31", "31-Balboa"], | |
["31AX", "31AX-Balboa A Express"], | |
["31BX", "31BX-Balboa B Express"], | |
["33", "33-Stanyan"], | |
["35", "35-Eureka"], | |
["36", "36-Teresita"], | |
["37", "37-Corbett"], | |
["38", "38-Geary"], | |
["38AX", "38AX-Geary A Express"], | |
["38BX", "38BX-Geary B Express"], | |
["38L", "38L-Geary Limited"], | |
["39", "39-Coit"], | |
["41", "41-Union"], | |
["43", "43-Masonic"], | |
["44", "44-O'Shaughnessy"], | |
["45", "45-Union/Stockton"], | |
["47", "47-Van Ness"], | |
["48", "48-Quintara/24th Street"], | |
["49", "49-Mission/Van Ness"], | |
["52", "52-Excelsior"], | |
["54", "54-Felton"], | |
["56", "56-Rutland"], | |
["66", "66-Quintara"], | |
["67", "67-Bernal Heights"], | |
["71", "71-Haight/Noriega"], | |
["71L", "71L-Haight/Noriega Limited"], | |
["76X", "76X-Marin Headlands Express"], | |
["81X", "81X-Caltrain Express"], | |
["82X", "82X-Levi Plaza Express"], | |
["83X", "83X-Caltrain"], | |
["88", "88-Bart Shuttle"], | |
["90", "90-San Bruno Owl"], | |
["91", "91-Owl"], | |
["108", "108-Treasure Island"], | |
["K OWL", "K-Owl"], | |
["L OWL", "L-Owl"], | |
["M OWL", "M-Owl"], | |
["N OWL", "N-Owl"], | |
["T OWL", "T-Owl"], | |
["59", "Powell/Mason Cable Car"], | |
["60", "Powell/Hyde Cable Car"], | |
["61", "California Cable Car"]]; | |
// insert the routes into the drop down and also create a lookup hash | |
_(routesToInsert).each(function(route){ | |
$("#RouteSelector").append('<option value="'+ route[0] +'">'+ route[1] +'</option>'); | |
nb.routesInfo[route[0]] = {}; | |
nb.routesInfo[route[0]].title = route[1]; | |
}); | |
var recursiveAsyncGoodness = function(queries, indx) { | |
indx = indx || 0; | |
if(queries && queries.length > 0) { | |
if(indx < queries.length) { | |
var stopTag = queries[indx].s, | |
routeTag = queries[indx].r; | |
nb.getNextbus({command: 'routeConfig', a:'sf-muni', r: routeTag}, function(xml) { | |
nb.routesInfo[routeTag] = nb.parseXMLstops(xml); | |
(indx===0) ? charts.push(makeChart(stopTag, routeTag)) : updateChart(stopTag, routeTag, charts[0]); | |
recursiveAsyncGoodness(queries, indx+1); | |
}); | |
} else { | |
makeChartView(charts[0]); | |
updateFormView(queries[0].s, queries[0].r); | |
} | |
} | |
}; | |
var params = window.location.search; | |
if(params.length>0) { | |
var queries = deserialiseParams(params.substr(1)); | |
recursiveAsyncGoodness(queries); | |
} | |
}); | |
// deparam - thanks Ben Alman! | |
// https://gist.github.com/cowboy/1025817 | |
(function($) { | |
// Creating an internal undef value is safer than using undefined, in case it | |
// was ever overwritten. | |
var undef; | |
// A handy reference. | |
var decode = decodeURIComponent; | |
// Document $.deparam. | |
var deparam = $.deparam = function(text, reviver) { | |
// The object to be returned. | |
var result = {}; | |
// Iterate over all key=value pairs. | |
$.each(text.replace(/\+/g, ' ').split('&'), function(index, pair) { | |
// The key=value pair. | |
var kv = pair.split('='); | |
// The key, URI-decoded. | |
var key = decode(kv[0]); | |
// Abort if there's no key. | |
if ( !key ) { return; } | |
// The value, URI-decoded. If value is missing, use empty string. | |
var value = decode(kv[1] || ''); | |
// If key is more complex than 'foo', like 'a[]' or 'a[b][c]', split it | |
// into its component parts. | |
var keys = key.split(']['); | |
var last = keys.length - 1; | |
// Used when key is complex. | |
var i = 0; | |
var current = result; | |
// If the first keys part contains [ and the last ends with ], then [] | |
// are correctly balanced. | |
if ( keys[0].indexOf('[') >= 0 && /\]$/.test(keys[last]) ) { | |
// Remove the trailing ] from the last keys part. | |
keys[last] = keys[last].replace(/\]$/, ''); | |
// Split first keys part into two parts on the [ and add them back onto | |
// the beginning of the keys array. | |
keys = keys.shift().split('[').concat(keys); | |
// Since a key part was added, increment last. | |
last++; | |
} else { | |
// Basic 'foo' style key. | |
last = 0; | |
} | |
if ( $.isFunction(reviver) ) { | |
// If a reviver function was passed, use that function. | |
value = reviver(key, value); | |
} else if ( reviver ) { | |
// If true was passed, use the built-in $.deparam.reviver function. | |
value = deparam.reviver(key, value); | |
} | |
if ( last ) { | |
// Complex key, like 'a[]' or 'a[b][c]'. At this point, the keys array | |
// might look like ['a', ''] (array) or ['a', 'b', 'c'] (object). | |
for ( ; i <= last; i++ ) { | |
// If the current key part was specified, use that value as the array | |
// index or object key. If omitted, assume an array and use the | |
// array's length (effectively an array push). | |
key = keys[i] !== '' ? keys[i] : current.length; | |
if ( i < last ) { | |
// If not the last key part, update the reference to the current | |
// object/array, creating it if it doesn't already exist AND there's | |
// a next key. If the next key is non-numeric and not empty string, | |
// create an object, otherwise create an array. | |
current = current[key] = current[key] || (isNaN(keys[i + 1]) ? {} : []); | |
} else { | |
// If the last key part, set the value. | |
current[key] = value; | |
} | |
} | |
} else { | |
// Simple key. | |
if ( $.isArray(result[key]) ) { | |
// If the key already exists, and is an array, push the new value onto | |
// the array. | |
result[key].push(value); | |
} else if ( key in result ) { | |
// If the key already exists, and is NOT an array, turn it into an | |
// array, pushing the new value onto it. | |
result[key] = [result[key], value]; | |
} else { | |
// Otherwise, just set the value. | |
result[key] = value; | |
} | |
} | |
}); | |
return result; | |
}; | |
// Default reviver function, used when true is passed as the second argument | |
// to $.deparam. Don't like it? Pass your own! | |
deparam.reviver = function(key, value) { | |
var specials = { | |
'true': true, | |
'false': false, | |
'null': null, | |
'undefined': undef | |
}; | |
return (+value + '') === value ? +value // Number | |
: value in specials ? specials[value] // true, false, null, undefined | |
: value; // String | |
}; | |
}(jQuery)); |