diff --git a/app.py b/app.py index a8e4057329b3cc1eb08b85c2f3e213638290ef66..e52923e0c75fdf51c693163c869c7560e687fe4f 100644 --- a/app.py +++ b/app.py @@ -30,12 +30,15 @@ def map(): r.headers.add('X-Frame-Options', 'DENY') return r -@app.route('/e') -def map_embed(): +@app.route('/e<mode>') +def map_embed(mode): # TODO: serve different agency depending on cookie (or special domain) agency_tag = app.config['AGENCIES'][0] agency = db.session.query(Agency).filter(Agency.tag==agency_tag).one() - return render_template('map.html', agency=agency, config=app.config, embed=True) + if not mode or mode == "m": + return render_template('map.html', agency=agency, config=app.config, embed=True) + elif mode == "p": + return render_template('predictions.html', agency=agency, config=app.config) @app.route('/ajax') def ajax(): @@ -100,10 +103,33 @@ def ajax(): "predictions": {p.id: p.serialize() for p in predictions} } + def predictions(): + """ Serve arrival predictions only. """ + from models import Prediction + now = datetime.now() + p_inner = db.session.query(Prediction.vehicle, Prediction.stop_id, + db.func.max(Prediction.api_call_id).label("api_call_id"))\ + .group_by(Prediction.vehicle, Prediction.stop_id)\ + .subquery() + predictions = db.session.query(Prediction).join(p_inner, db.and_( + p_inner.c.api_call_id == Prediction.api_call_id, + p_inner.c.vehicle == Prediction.vehicle, + p_inner.c.stop_id == Prediction.stop_id + )).filter( + Agency.tag==agency_tag, + Prediction.prediction >= now)\ + .group_by(Prediction.id, Prediction.vehicle, Prediction.stop_id)\ + .all() + return { + "predictions": {p.vehicle: p.serialize() for p in predictions}, + } + if query == "routes": return jsonify(routes()) elif query == "vehicles": return jsonify(vehicles()) + elif query == "predictions": + return jsonify(predictions()) elif query == "modal": modal_name = request.args.get('modal_name') return modal(modal_name) diff --git a/static/css/predictions.css b/static/css/predictions.css new file mode 100644 index 0000000000000000000000000000000000000000..5c9174a5e402b76209dcc0ea2487736a919ce733 --- /dev/null +++ b/static/css/predictions.css @@ -0,0 +1,5 @@ +#predictions { + width: 100%; + height: 1em; + background-color: #f00; +} diff --git a/static/js/embed.js b/static/js/embed.js index 7107a0e63c628a2a4a7238e19d6bd0d863fcb8e5..69a80a851d3f1c757840c998ceeb0fd992ac19f3 100644 --- a/static/js/embed.js +++ b/static/js/embed.js @@ -1,37 +1,100 @@ -/* Logic for embed code generator form */ +BusMap.Embed = function(opts) { + this.opts = opts; + var embedVars = {}; + var that = this; -var updateCode = function() { - // Grab HTML representation of preview; trim whitespace. - var code = $("#embed-preview").html().trim(); - // Remove empty style attribute. OCD is good! - code = code.replace(" style=''",""); - code = code.replace(' style=""',""); - // Put it in the textarea. - $("#embed-code").html(code); -} + /* Constructor - initialize the embed form */ + function init() { + populateOptions(); + bindEventHandlers(); + } + + var updateEmbedCode = function() { + // Grab HTML representation of preview; trim whitespace. + var code = $("#embed-preview").html().trim(); + // Remove empty style attribute. OCD is good! + code = code.replace(" style=''",""); + code = code.replace(' style=""',""); + // Put it in the textarea. + $("#embed-code").html(code); + } -// OnChange event handlers for Embed Options form -$("#embed-height").change(function() { - $("#embed-preview iframe").attr('height', $(this).val() + "px"); -}); -$("#embed-width").change(function() { - $("#embed-preview iframe").attr('width', $(this).val() + "px"); -}); -$("#embed-responsive").change(function() { - if ($(this).prop("checked")) { - $("#embed-preview iframe").css('max-width', '100%'); - } else { - $("#embed-preview iframe").css('max-width', ''); + var populateOptions = function() { + var stops = window._busmap.stops; + stops = Object.keys(stops).map(function(i){return stops[i]}); + stops.sort(function(a,b){ return a.title > b.title; }); + for (id in stops) { + var s = stops[id]; + var opt = $('<option value="' + s.id + '">' + s.title + '</option>'); + opt.appendTo("#embed-stop"); + } } -}); -$("#embed-form :input").change(function() { - updateCode(); -}); -// Hilight code for easy copying -$("#embed-code").on('focus click keydown keyup', function() { - $(this).select(); -}); + var updateEmbedUrl = function() { + var newUrl = opts.baseUrl; + if (embedVars.mode) newUrl += embedVars.mode; + if (embedVars.stop) { + if (embedVars.popup) newUrl += "#S" + embedVars.stop; + else newUrl += "#s" + embedVars.stop; + } + $("#embed-preview iframe").get(0).src = newUrl; + } + + var bindEventHandlers = function() { + // OnChange event handlers for Embed Options form + $("#embed-height").change(function() { + if ($(this).val() != "") { + $("#embed-preview iframe").attr('height', $(this).val() + "px"); + } else { + $("#embed-preview iframe").removeAttr('height'); + } + }); + $("#embed-width").change(function() { + if ($(this).val() != "") { + $("#embed-preview iframe").attr('width', $(this).val() + "px"); + } else { + $("#embed-preview iframe").removeAttr('width'); + } + }); + $("#embed-responsive").change(function() { + if ($(this).prop("checked")) { + $("#embed-preview iframe").css('max-width', '100%'); + } else { + $("#embed-preview iframe").css('max-width', ''); + } + }); + $("#embed-mode").change(function() { + embedVars['mode'] = $(this).val(); + updateEmbedUrl(); + }); + $("#embed-stop").change(function() { + if ($(this).val()) { + embedVars['stop'] = $(this).val(); + } else { + delete embedVars['stop']; + } + updateEmbedUrl(); + }); + $("#embed-popup").change(function() { + if ($(this).prop("checked")) { + embedVars['popup'] = true; + } else { + delete embedVars['popup']; + } + updateEmbedUrl(); + }); + // After ANY field is updated... + $("#embed-form :input").change(function() { + updateEmbedCode(); + }); + // Hilight code for easy copying on click, key, focus. + $("#embed-code").on('focus click keydown keyup', function() { + $(this).select(); + }); + // Fire a fake change event to ensure sync between form, preview, and code. + $("#embed-form :input").change(); + } -// Fire a fake change event to ensure sync between form, preview, and code. -$("#embed-form :input").change(); + init(); + return that; +} diff --git a/static/js/map.js b/static/js/map.js index 979a20a44015a86c9ef46561089d5e87c31b593e..607a20dc4d16e8ebe0075e9c64ee9847b2274708 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -16,6 +16,7 @@ BusMap.Map = function(opts) { var stops = {}; var routes = {}; var that = this; + window._busmap = this; /* Constructor - create/initialize the map */ function init() { @@ -33,7 +34,7 @@ BusMap.Map = function(opts) { if (that.opts.zoom) that.leaflet.setZoom(that.opts.zoom); // Go to view requested by URL hash (if set) - var viewOk = that.setViewFromUrlHash(); + var viewOk = setViewFromUrl(); if (!viewOk && !that.opts.embed) { // Restore the user's last view (if exists). @@ -43,9 +44,14 @@ BusMap.Map = function(opts) { // Store view parameters for recovery later. that.leaflet.on('moveend', lastViewStore); that.leaflet.on('moveend', function() { - window.location.replace("#" + getViewString()); + setUrlFromView(); }); + // Listen for hash change + window.onhashchange = function() { + setViewFromUrl(); + }; + // Show/hide markers based on zoom. that.leaflet.on('zoomend', zoomShowHide); @@ -77,10 +83,10 @@ BusMap.Map = function(opts) { if (!that.opts.embed) { // UI stuff for regular mode - $('<a>About</a>').click(function() { showModal('about') }) + $('<a>About</a>').attr('href', '#;about') .appendTo('.leaflet-control-attribution'); $('.leaflet-control-attribution').append(" | "); - $('<a>Embed</a>').click(function() { showModal('embed') }) + $('<a>Embed</a>').attr('href', '#;embed') .appendTo('.leaflet-control-attribution'); // Display welcome screen on first launch var been_here = BusMap.getCookie('been_here'); @@ -341,8 +347,7 @@ BusMap.Map = function(opts) { } function applyViewString(view) { if (!view || view == "") return false; - if (view.charAt(0) == "s") return _avsStop(view); - if (view.charAt(0) == "v") return _avsVehicle(view); + if (view.charAt(0).toLowerCase() == "s") return _avsStop(view); window.location.replace("#" + view); return _avsLatLonZoom(view); function _avsStop(view) { @@ -350,16 +355,15 @@ BusMap.Map = function(opts) { return setTimeout(function() { _avsStop(view) }, 500); } var marker = that.stopMarkers[view.substring(1)]; - var ll = marker.getLatLng(); - that.leaflet.setView(ll, that.stopZoom); - } - function _avsVehicle(view) { - if (!that.vehicleMarkers) { - return setTimeout(function() { _avsVehicle(view) }, 500); + if (marker) { + var ll = marker.getLatLng(); + that.leaflet.setView(ll, that.stopZoom); + } else console.log('Unknown stop in view string: ' + view.substring(1)); + if (view.charAt(0) == "S") { + marker.openPopup(); + } else if (marker.getPopup()._isOpen) { + marker.closePopup(); } - var marker = that.vehicleMarkers[view.substring(1)]; - var ll = marker.getLatLng(); - that.leaflet.setView(ll, that.vehicleZoom); } function _avsLatLonZoom(view) { view = view.split(","); @@ -367,7 +371,7 @@ BusMap.Map = function(opts) { that.leaflet.setView([view[0], view[1]], view[2]); } else if (view.length == 2) { that.leaflet.setView([view[0], view[1]]); - } else return false; + } else console.log('Invalid view string: ' + view); } } function lastViewRecover() { @@ -382,9 +386,12 @@ BusMap.Map = function(opts) { // Display a modal pop-up function showModal(name) { $('div.modal').remove(); - var modal = $('<div class="modal"></div>'); + var modal = $('<div class="modal" id="modal-'+name+'"></div>'); var closeBtn = $('<div class="close">×</div>').appendTo(modal); - closeBtn.click(function() { $(this).parent().remove(); }); + closeBtn.click(function() { + $(this).parent().remove(); + setUrlFromView(); + }); $("#map").after(modal); modal.show(); var params = { @@ -395,6 +402,7 @@ BusMap.Map = function(opts) { $.get("ajax", params).done(function(contents) { modal.append(contents); }); + setUrlFromView(); } // Scaling: update what is displayed based on zoom level @@ -411,7 +419,12 @@ BusMap.Map = function(opts) { } } - that.setViewFromUrlHash = function() { + function setViewFromUrl() { + if (that.hashChangedProgrammatically ) { + // Ignore hash changes caused programmatically (would inf-loop). + delete that['hashChangedProgrammatically']; + return; + } var hash = window.location.hash.substring(1); if (hash == "") return false; var parts = hash.split(";"); @@ -423,9 +436,14 @@ BusMap.Map = function(opts) { } } - that.getUrlFromView = function() { + function setUrlFromView() { var view = getViewString(); - return window.location.hostname + window.location.pathname + '#' + view; + if ($(".modal").length > 0) { + var modalName = $(".modal").attr('id').split('-')[1]; + view += ";" + modalName; + } + that.hashChangedProgrammatically = true; // avoid infinite loop! + window.location.replace("#" + view); } init(); diff --git a/static/js/predictions.js b/static/js/predictions.js new file mode 100644 index 0000000000000000000000000000000000000000..5998757431a8b26fc53b1ac6dd420d7828891bc3 --- /dev/null +++ b/static/js/predictions.js @@ -0,0 +1,128 @@ +BusMap.Predictions = function(opts) { + this.opts = opts; + var stops = {}; + var routes = {}; + var that = this; + + /* Constructor - create/initialize the map */ + function init() { + // Fetch initial data + updateRoutes(); + updatePredictions(); + + // Begin timed data updates + if (that.opts.refresh.routes) { + setInterval(updateRoutes, that.opts.refresh.routes * 1000); + } + if (that.opts.refresh.vehicles) { + setInterval(updateVehicles, that.opts.refresh.vehicles * 1000); + } + + }; + + /* Get Routes (and Stops, and Directions) */ + function updateRoutes() { + var url = "ajax"; + var params = { + query: "routes", + agency: that.opts.agency, + }; + $.getJSON(url, params) + .done(function(data) { + that.stops = data.stops; + that.routes = data.routes; + updateStopsUI(that.stops); + }); + return that; + }; + + /* Get Vehicles (and Predictions) */ + function updatePredictions() { + var url = "ajax"; + var params = { + query: "predictions", + agency: that.opts.agency, + }; + $.getJSON(url, params) + .done(function(data) { + // Store predictions + for (var s in that.stops) { + that.stops[s].predictions = {}; + } + for (var p in data.predictions) { + pr = data.predictions[p]; + if (that.stops && pr.stop_id in that.stops) { + // Store this prediction with the relevant stop + if (!(pr.route in that.stops[pr.stop_id].predictions)) { + that.stops[pr.stop_id].predictions[pr.route] = []; + } + that.stops[pr.stop_id].predictions[pr.route].push(pr); + } + if (that.vehicles && pr.vehicle in that.vehicles) { + // Store this prediction with the relevant vehicle + that.vehicles[pr.vehicle].predictions.push(pr); + } + } + updatePredictionsUI(that.stops); + }); + return that; + }; + + /* Refresh (and/or create) UI elements for Stops */ + function updateStopsUI(stops) { + // TODO: this. usually this would be for a single stop but maybe multiple. + // identified via some option. that.opts.stops = ["foo", "bar", "baz"]? + // or...get it from the URL hash (like the map does)? + } + + /* Refresh (and/or create) UI elements for Predictions */ + function updatePredictionsUI(stops) { + // TODO: Generate predictions ui view + for (var s in stops) { + var text = '<header>' + stops[s].title + '</header>'; + if (stops[s].predictions) { + var predictions = []; + /* + var now = new Date(); + var offset_mins = now.getTimezoneOffset(); + for (r in stops[s].predictions) { + if (!(r in that.routes)) { + console.log("Unknown route " + r + " for stop " + stops[s].title); + } + var p_line = "<strong>" + that.routes[r].title + "</strong>: "; + var times = []; + // Sort by estimated time to arrival + psorted = stops[s].predictions[r].sort(function(a,b){ + return new Date(a.prediction).getTime() - new Date(b.prediction).getTime(); + }); + for (p in psorted) { + pr = psorted[p]; + var pdate = new Date(pr.prediction); + var diff_sec = (pdate.getTime() - now.getTime()) / 1000; + var diff_min = Math.ceil(diff_sec / 60) + offset_mins; + // CSS classes for predictions + var pclass = ""; + if (diff_min <= 1) { pclass = "lt1min"; } + else if (diff_min <= 2 ) { pclass = "lt2mins"; } + else if (diff_min <= 5 ) { pclass = "lt5mins"; } + times.push("<span class='prediction " + pclass + + "' title='" + pr.vehicle + "'>" + + diff_min + "</span>"); + } + p_line += times.join(", "); + predictions.push("<span class='route'" + p_line + "</span>"); + } + */ + if (predictions.length == 0) { + predictions = ['<span class="none">No arrival predictions.</span>']; + } + text += '<section class="predictions stop-predictions">' + + predictions.sort().join("<br>") + '</section>'; + // TODO: put output somewhere... + } + } + } + + init(); + return that; +} diff --git a/templates/modal-embed.html b/templates/modal-embed.html index 9e7aa4b7f2a517bece3fcf1741f2a69f326328a5..312a8c6508bb38ece909b6520d27bdb6af1ec445 100644 --- a/templates/modal-embed.html +++ b/templates/modal-embed.html @@ -3,10 +3,12 @@ This map can be used in websites, displays, or digital signage solutions. To embed a list of predictions instead, change the <strong>Mode</strong> selector. </p> <h3>Embed Code</h3> -<textarea id="embed-code" cols=100 rows=3 readonly=true><!-- Loading... --></textarea> +<textarea id="embed-code" cols=100 rows=3 readonly=true></textarea> <h3>Preview</h3> <div id="embed-preview"> -<iframe src="{{ url_for('map_embed', _external=True, _scheme='') }}" frameborder=0></iframe> + <iframe + src="{{ url_for('map_embed', mode='m', _external=True, _scheme='') }}" + frameborder=0></iframe> </div> <h3>Options</h3> <form id="embed-form"> @@ -14,12 +16,26 @@ <label for="embed-mode"> <span>Mode:</span> <select id="embed-mode"> - <option value="map">Map</option> - <option value="predictions">Predictions</option> - <option value="split">Combined</option> + <option value="m">Map</option> + <option value="p">Predictions</option> + <option value="c">Combined</option> </select> </label> </p> + <p> + <label for="embed-stop"> + <span>Stop:</span> + <select id="embed-stop"> + <option value="" selected disabled>Select a stop</option> + </select> + </label> + </p> + <p> + <label for="embed-popup"> + <span>Show Popup:</span> + <input id="embed-popup" type="checkbox" checked> + </label> + </p> <p> <fieldset> <legend>Size</legend> @@ -50,4 +66,10 @@ <p class="legalese"> No guarantees are made about availability, performance, or suitability of this service for any purpose. </p> -<script src="static/js/embed.js"></script> +<script> + $.getScript('static/js/embed.js', function() { + BusMap.Embed({ + baseUrl: "{{ url_for('map_embed', mode='', _external=True, _scheme='') }}", + }); + }); +</script> diff --git a/templates/predictions.html b/templates/predictions.html new file mode 100644 index 0000000000000000000000000000000000000000..ad582e3967029c59102c2d80862d40ec54e05d4b --- /dev/null +++ b/templates/predictions.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title -%} + {% if agency.short_title %}{{ agency.short_title -}} + {% elif agency.title %}{{ agency.title }}{% endif %} Bus Predictions +{%- endblock %} +{% block head %} + {{ super() }} + <link href="static/css/predictions.css" rel="stylesheet" /> +{% endblock %} +{% block body %} + <div id="predictions"></div> + <script src="bower/jquery/dist/jquery.min.js"></script> + <script src="static/js/map.js"></script> + <script src="static/js/predictions.js"></script> + <script> + var predictions = BusMap.Predictions({ + agency: {{ agency.tag|tojson|safe }}, + predictionsElement: $("#predictions").get(0), + refresh: 60, + }); + </script> +{% endblock %}