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)); |