diff --git a/app.py b/app.py index 2c6e924480f87590aeadf667e9c29f13b7f8ed2a..4c479fb24a21e5af536cabce44d65af9173f23d8 100644 --- a/app.py +++ b/app.py @@ -1,8 +1,9 @@ import os -from flask import Flask, jsonify, render_template, request +from flask import Flask, jsonify, make_response, render_template, request, abort from flask.ext.bower import Bower +from jinja2.exceptions import TemplateNotFound from sqlalchemy.orm import joinedload -from models import db +from models import db, Agency from datetime import datetime app = Flask(__name__, instance_relative_config=True) @@ -22,30 +23,45 @@ Bower(app) # Flask Web Routes @app.route('/') def map(): - from models import Agency # 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) + r = make_response(render_template('map.html', agency=agency, config=app.config)) + r.headers.add('X-Frame-Options', 'DENY') + return r -@app.route('/embed') -def map_embed(): - from models import Agency +@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 == "c": + return render_template('combined.html', agency=agency, config=app.config, embed=True) + elif mode == "p": + return render_template('predictions.html', agency=agency, config=app.config, embed=True) @app.route('/ajax') def ajax(): - # TODO: OPTIMIZE THIS SHIT. - # Over 1sec/request just to get predictions? Fuck that noise. - dataset = request.args.get('dataset') - agency = request.args.get('agency') + """ Handle all async requests (from JS). """ + query = request.args.get('query') + agency_tag = app.config['AGENCIES'][0] + agency = db.session.query(Agency).filter(Agency.tag==agency_tag).one() + def modal(name): + """ Serve contents of a modal popup """ + if not name.isalnum(): + return abort(404) + template_name = 'modal-' + name + '.html' + try: + return render_template(template_name, agency=agency, config=app.config) + except TemplateNotFound: + return abort(404) def routes(): - from models import Agency, Route, RouteStop, Stop + """ Serve the route configuration (routes, stops) """ + from models import Route, RouteStop, Stop routes = db.session.query(Route).join(Agency)\ - .filter(Agency.tag==agency).all() + .filter(Agency.tag==agency_tag).all() stops = db.session.query(Stop).options(joinedload(Stop.routes))\ .filter(Stop.routes.any(Route.id.in_([r.id for r in routes]))).all() return { @@ -54,8 +70,12 @@ def ajax(): } def vehicles(): - from models import Agency, Route, VehicleLocation, Prediction + """ Serve the current vehicle locations and arrival predictions. """ + from models import Route, VehicleLocation, Prediction + # TODO: OPTIMIZE + # Over 1sec/request just to get predictions? Fuck that noise. # TODO: Somehow bundle these queries into the object model definitions? So messy :( + # maybe VehicleLocation.get_latest_for_agency(a)? # 1. Select the latest vehicle locations for each vehicle. (The DB may have old ones too). v_inner = db.session.query(VehicleLocation.vehicle, db.func.max(VehicleLocation.time).label("time"))\ @@ -63,7 +83,7 @@ def ajax(): vehicle_locations = db.session.query(VehicleLocation).join(v_inner, db.and_( v_inner.c.vehicle == VehicleLocation.vehicle, v_inner.c.time == VehicleLocation.time - )).filter(Agency.tag==agency).all() + )).filter(Agency.tag==agency_tag).all() # 2. Select the predictions for each vehicle:stop pair which came from the most recent # API call for that vehicle:stop pair. Old predictions may be stored but we don't want them. now = datetime.now() @@ -76,22 +96,50 @@ def ajax(): p_inner.c.vehicle == Prediction.vehicle, p_inner.c.stop_id == Prediction.stop_id )).filter( - Agency.tag==agency, + Agency.tag==agency_tag, Prediction.prediction >= now)\ .group_by(Prediction.id, Prediction.vehicle, Prediction.stop_id)\ .all() - - z = { + return { "locations": {v.vehicle: v.serialize() for v in vehicle_locations}, "predictions": {p.id: p.serialize() for p in predictions} } - return z - if dataset == "routes": - r = jsonify(routes()) - elif dataset == "vehicles": - r = jsonify(vehicles()) - return r + def predictions(): + """ Serve arrival predictions only. """ + from models import Prediction + stops = request.args.get('stops') + 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) + if stops: + predictions = predictions.filter( + Prediction.stop_id.in_(stops.split(','))) + predictions = predictions\ + .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) if __name__ == '__main__': # Run Flask diff --git a/bower.json b/bower.json index 362ee25ea78945692e8bae9fd1d74efcbe15e1cd..8aa20b7d0850f18fa1adef0d324f4e96962c1ced 100644 --- a/bower.json +++ b/bower.json @@ -15,7 +15,7 @@ ], "dependencies": { "leaflet": "~0.7.7", - "jquery": "~2.1.4", + "jquery": "~2.2.3", "leaflet.markercluster": "~0.4.0", "leaflet-marker-rotate": "https://git.xhost.io/anton/leaflet-marker-rotate.git", "Leaflet.label": "~0.2.1" diff --git a/celerytasks.py b/celerytasks.py index c26a2d7c061b9922c26911d94f9f68f251b08f06..983ad82b4a91994867c60d00221aae479c936aa7 100644 --- a/celerytasks.py +++ b/celerytasks.py @@ -5,6 +5,7 @@ import time from app import app, db from models import Agency, Prediction from nextbus import Nextbus +from lock import LockException """ Celery is a task queue for background task processing. We're using it @@ -44,11 +45,10 @@ def update_routes(agencies=None): """ if not agencies: agencies = app.config['AGENCIES'] - route_count = 0 for agency_tag in agencies: - route_count += len(Nextbus.get_routes(agency_tag, truncate=True)) + routes = Nextbus.get_routes(agency_tag, truncate=True) print("update_routes: Got {0} routes for {1} agencies"\ - .format(route_count, len(agencies))) + .format(len(routes), len(agencies))) @celery.task() def update_predictions(agencies=None): @@ -58,10 +58,11 @@ def update_predictions(agencies=None): start = time.time() if not agencies: agencies = app.config['AGENCIES'] - prediction_count = len(Nextbus.get_predictions(agencies, truncate=False)) + predictions = Nextbus.get_predictions(agencies, + truncate=False) elapsed = time.time() - start - print("Got {0} predictions for {1} agencies in {2:0.2f} sec."\ - .format(prediction_count, len(agencies), elapsed)) + print("Got {0} predictions for {1} agencies in {2:0.2f} seconds."\ + .format(len(predictions), len(agencies), elapsed)) @celery.task() def update_vehicle_locations(agencies=None): @@ -71,10 +72,16 @@ def update_vehicle_locations(agencies=None): start = time.time() if not agencies: agencies = app.config['AGENCIES'] - vl_count = len(Nextbus.get_vehicle_locations(agencies, truncate=False)) + try: + vl = Nextbus.get_vehicle_locations(agencies, + truncate=False, + max_wait=0) + except LockException as e: + print(e) + return elapsed = time.time() - start print("Got {0} vehicle locations for {1} agencies in {2:0.2f} seconds."\ - .format(vl_count, len(agencies), elapsed)) + .format(len(vl), len(agencies), elapsed)) @celery.task() diff --git a/nextbus.py b/nextbus.py index 2e88f85d05a926254d299319d8169950593d7e2b..4ca67cbb17ef13c33ec2959587de7d09268df51a 100644 --- a/nextbus.py +++ b/nextbus.py @@ -366,13 +366,15 @@ class Nextbus(): return predictions @classmethod - def get_vehicle_locations(cls, agency_tags, truncate=True): + def get_vehicle_locations(cls, agency_tags, truncate=True, max_wait=10): """ Get vehicle GPS locations """ if not agency_tags: return [] - with Lock("agencies", shared=True), Lock("routes", shared=True), Lock("vehicle_locations"): + with Lock("agencies", shared=True, timeout=max_wait),\ + Lock("routes", shared=True, timeout=max_wait),\ + Lock("vehicle_locations", timeout=max_wait): db.session.begin() routes = db.session.query(Route).join(Agency)\ .options(joinedload('directions'))\ diff --git a/static/css/combined.css b/static/css/combined.css new file mode 100644 index 0000000000000000000000000000000000000000..cf2b835226fd9df467445c108af3c96db5451432 --- /dev/null +++ b/static/css/combined.css @@ -0,0 +1,23 @@ +body { + display: flex; + margin: 0; + padding: 0; +} +.combined:not(:first-child) { + border-left: 2px solid #666; +} +.combined.map, +.combined.predictions { + margin: 0; + overflow: hidden; +} +.combined.map { + flex-basis: 20em; + flex-grow: 4; + flex-shrink: 1; +} +.combined.predictions { + flex-basis: 15em; + flex-shrink: 1; + flex-grow: 1; +} diff --git a/static/css/map.css b/static/css/map.css index 7adb1d8adfb92a0e1866385abd88bc405d65d812..f14f38224e9a21756ee831617c8172bf8228f0fd 100644 --- a/static/css/map.css +++ b/static/css/map.css @@ -5,19 +5,38 @@ html, body { border: 0; margin: 0; padding: 0; - font-family: sans-serif; + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 14px; } -#map { +input, +textarea, +select, +button { + max-width: 100%; +} +fieldset { + margin: 0; + border: none; + padding: .25em 1em; +} +legend { + padding: 0; + margin-left: -1em; +} +fieldset legend ~ label { + font-size: .9em; +} +.map { height: 100%; width: 100%; } -#map .prediction.lt1min { +.map .prediction.lt1min { color: #f00; } -#map .prediction.lt2mins { +.map .prediction.lt2mins { color: #f80; } -#map .prediction.lt5mins { +.map .prediction.lt5mins { color: #ff0; } @@ -26,7 +45,7 @@ html, body { position: absolute; bottom: 10px; left: 10px; - z-index: 5000; + z-index: 1001; } #locate a { text-decoration: none; @@ -40,8 +59,8 @@ html, body { } /* Embed Mode - Hide UI stuff */ -#map.embed .leaflet-control, -#map.embed ~ #locate { +.map.embed .leaflet-control-zoom, +.map.embed ~ #locate { display: none; } @@ -71,106 +90,139 @@ html, body { } /* Attribution link / "About" popup */ -#map .leaflet-control-attribution { +.map .leaflet-control-attribution { background-color: rgba(0,0,0,.8); border-top: 1px solid #eee; border-left: 1px solid #eee; border-top-left-radius: 3px; color: #eee; - z-index: 5000; } -#map .leaflet-control-zoom, -#map .leaflet-control-zoom a { +.map .leaflet-control-attribution a { + cursor: pointer; +} +.map .leaflet-control-zoom, +.map .leaflet-control-zoom a { border: 1px solid #666; background-color: #222; } -#map a, -#map a:hover, -#map a:visited, -#map a:active { +.map a, +.map a:hover, +.map a:visited, +.map a:active { color: #fff; font-weight: bold; text-decoration: none; } -#map div.leaflet-popup header, -#map div.leaflet-popup footer { +.map div.leaflet-popup header, +.map div.leaflet-popup footer { text-align: center; } -#map div.leaflet-popup footer { +.map div.leaflet-popup footer { opacity: .75; } -#map div.leaflet-popup .predictions .none { +.map div.leaflet-popup .predictions .none { opacity: .75; } -#map div.leaflet-popup .predictions.vehicle-predictions .stop:nth-child(n+6) { +.map div.leaflet-popup .predictions.vehicle-predictions .stop:nth-child(n+6) { display: none; } -#map div.leaflet-popup .show-all-predictions .predictions.vehicle-predictions .stop:nth-child(n+6) { +.map div.leaflet-popup .show-all-predictions .predictions.vehicle-predictions .stop:nth-child(n+6) { display: block; } -#map div.leaflet-popup .show-all-predictions .predictions.vehicle-predictions .more { +.map div.leaflet-popup .show-all-predictions .predictions.vehicle-predictions .more { display: none; } -#map div.leaflet-popup .predictions.vehicle-predictions .more { +.map div.leaflet-popup .predictions.vehicle-predictions .more { text-align: center; } -#map div.leaflet-popup div.leaflet-popup-content-wrapper, -#map div.leaflet-label { +.map div.leaflet-popup div.leaflet-popup-content-wrapper, +.map div.leaflet-label { border: 3px solid #666; background: #222; color: #eee; } -#map div.leaflet-popup div.leaflet-popup-tip { +.map div.leaflet-popup div.leaflet-popup-tip { background-color: #666; } -#map div.leaflet-popup div.leaflet-popup-content { +.map div.leaflet-popup div.leaflet-popup-content { margin: .5em .66em; } -#map div.leaflet-popup div.leaflet-popup-content header { +.map div.leaflet-popup div.leaflet-popup-content header { font-weight: bold; font-size: 1.3em; color: #fff; } -.dialog { +.modal { display: none; - background-color: #444; - color: #eee; + background-color: #fff; + color: #333; border-radius: 1em; box-sizing: border-box; border: 2px solid #888; position: absolute; - width: 80%; + top: 50%; left: 10%; - top: 20%; + width: 80%; + transform: translateY(-50%); padding: 1em; - max-height: 80%; + max-height: 100%; + margin: 0 auto; overflow: auto; + z-index: 2000; } -.dialog .close { +.modal .close { float: right; + cursor: pointer; } -.dialog h1, -.dialog h2 { +.modal hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + margin: 1em 0; + padding: 0; +} +.modal h1, +.modal h2 { text-align: center; } -.dialog a, -.dialog a:hover, -.dialog a:visited, -.dialog a:active { - color: #fff; +.modal .close + h1, +.modal .close + h2 { + margin-top: .5em; +} +.modal h3 { + margin-bottom: .5em; +} +.modal a, +.modal a:hover, +.modal a:visited, +.modal a:active { + color: #000; font-weight: bold; text-decoration: none; } -.dialog h1 a, -.dialog h2 a { +.modal h1 a, +.modal h2 a { text-decoration: underline; } +.embed-only { + display: none; +} +body.embed .embed-only { + display: block; +} +#embed-form input { + width: 5em; +} +#embed-form label span { + display: inline-block; + width: 8em; +} @media all and (max-width: 600px) { - .dialog { + .modal { width: 100%; left: 0; - top: 10%; border-radius: 0; border-left: none; border-right: none; diff --git a/static/css/predictions.css b/static/css/predictions.css new file mode 100644 index 0000000000000000000000000000000000000000..872932d9af5d52aa5cb8948b2a0a9d84f00cf30c --- /dev/null +++ b/static/css/predictions.css @@ -0,0 +1,27 @@ +body { + margin: 0; +} +.predictions { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + background-color: #222; + color: #fff; + padding: .4em; +} +.predictions header { + text-align: center; + font-weight: bold; + font-size: 1.2em; +} +.predictions p { + margin: .5em 0; +} +.predictions .prediction.lt1min { + color: #f00; +} +.predictions .prediction.lt2mins { + color: #f80; +} +.predictions .prediction.lt5mins { + color: #ff0; +} + diff --git a/static/js/embed.js b/static/js/embed.js new file mode 100644 index 0000000000000000000000000000000000000000..5d2db04fa8eccf52652721eaa7afd83e0a727bfd --- /dev/null +++ b/static/js/embed.js @@ -0,0 +1,119 @@ +BusMap.Embed = function(opts) { + var embedVars = {}; + var that = this.Embed; + that.opts = opts; + + /* Constructor - initialize the embed form */ + function init() { + bindEventHandlers(); + populateOptions(); + } + 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); + } + + var populateOptions = function() { + var stops = window._busmap.stops; + stops = Object.keys(stops).map(function(i){return stops[i]}); + stops = stops.sort(function(a,b){ + if (a.title > b.title) return 1; + else if (a.title < b.title) return -1; + return 0; + }); + for (id in stops) { + var s = stops[id]; + var opt = $('<option value="' + s.id + '">' + s.title + '</option>'); + opt.appendTo("#embed-stop"); + } + if ($("#embed-stop option").length > 1 && !$("#embed-stop").val()) { + // We have stops. + if (window._busmap.selectedStop) { + // Select the last one clicked if available. + $("#embed-stop").val(window._busmap.selectedStop); + } else { + // Select the first one. + $("#embed-stop option:nth-child(2)").prop('selected', true); + } + $("#embed-stop").change(); // fire event to handle the change + } + } + + 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").on('change', function() { + if ($(this).val() != "") { + $("#embed-preview iframe").attr('height', $(this).val() + "px"); + } else { + $("#embed-preview iframe").removeAttr('height'); + } + }); + $("#embed-width").on('change', function() { + if ($(this).val() != "") { + $("#embed-preview iframe").attr('width', $(this).val() + "px"); + } else { + $("#embed-preview iframe").removeAttr('width'); + } + }); + $("#embed-responsive").on('change', function() { + if ($(this).prop("checked")) { + $("#embed-preview iframe").css('max-width', '100%'); + } else { + $("#embed-preview iframe").css('max-width', ''); + } + }); + $("#embed-mode").on('change', function() { + embedVars['mode'] = $(this).val(); + updateEmbedUrl(); + if ($(this).val() == "m" || $(this).val() == "c") { + $("label[for=embed-popup]").show(); + } else { + $("label[for=embed-popup]").hide(); + } + }); + $("#embed-stop").on('change', function() { + if ($(this).val()) { + embedVars['stop'] = $(this).val(); + } else { + delete embedVars['stop']; + } + updateEmbedUrl(); + }); + $("#embed-popup").on('change', function() { + if ($(this).prop("checked")) { + embedVars['popup'] = true; + } else { + delete embedVars['popup']; + } + updateEmbedUrl(); + }); + // After ANY field is updated... + $("#embed-form :input").on('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(); + } + + init(); + return that; +} diff --git a/static/js/map.js b/static/js/map.js index e48570b10c58e08a6ea3de4197f291a5d7aac9d8..dab43c0a7b6c4e3ae9928c1066f4929c467c1d4f 100644 --- a/static/js/map.js +++ b/static/js/map.js @@ -2,6 +2,7 @@ var BusMap = { cookiePrefix: "BM_", zoomShowVehicles: 15, + stopZoom: 16, vehicleMaxAge: 10, }; @@ -10,10 +11,12 @@ var BusMap = { Updating the map (Vehicles, Routes) is also handled here. */ BusMap.Map = function(opts) { - this.opts = opts; var stops = {}; var routes = {}; - var that = this; + var modalCache = {}; + var that = this.Map; + that.opts = opts; + window._busmap = this.Map; /* Constructor - create/initialize the map */ function init() { @@ -23,15 +26,20 @@ BusMap.Map = function(opts) { animate: false, reset: true, }; - that.leaflet = L.map(that.opts.mapElement, mapOptions) + + that.map = that.opts.mapElement; + that.leaflet = L.map(that.map, mapOptions) .fitBounds(that.opts.bounds) .setMaxBounds(that.opts.bounds, boundsOptions); if (that.opts.center) { that.leaflet.setView(that.opts.center); } else {that.leaflet.fitBounds(that.opts.bounds)} if (that.opts.zoom) that.leaflet.setZoom(that.opts.zoom); + // Listen for hash change + $(window).on('hashchange', setViewFromUrl); + // 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). @@ -40,9 +48,12 @@ BusMap.Map = function(opts) { // Store view parameters for recovery later. that.leaflet.on('moveend', lastViewStore); - that.leaflet.on('moveend', function() { - window.location.replace("#" + getViewString()); - }); + if (that.opts.hashUpdate !== false) { + that.leaflet.on('moveend', setUrlFromView); + } + + // Hide any opened modals when map is clicked. + $(that.map).on('mousedown', hideModal); // Show/hide markers based on zoom. that.leaflet.on('zoomend', zoomShowHide); @@ -52,7 +63,7 @@ BusMap.Map = function(opts) { var tileOptions = { }; if (that.opts.tileOptions) { - for (o in that.opts.tileOptions) { + for (var o in that.opts.tileOptions) { tileOptions[o] = that.opts.tileOptions[o]; } } @@ -61,6 +72,7 @@ BusMap.Map = function(opts) { // Fetch initial data updateRoutes(); updateVehicles(); + // Begin timed data updates if (that.opts.refresh.routes) { setInterval(updateRoutes, that.opts.refresh.routes * 1000); @@ -68,13 +80,44 @@ BusMap.Map = function(opts) { if (that.opts.refresh.vehicles) { setInterval(updateVehicles, that.opts.refresh.vehicles * 1000); } + + // Remove leaflet default attribution (replaced later) + $('.leaflet-control-attribution').html(""); + + if (!that.opts.embed) { + // UI stuff for regular mode + $('<a>About</a>').attr('href', '#;about') + .appendTo('.leaflet-control-attribution'); + $('.leaflet-control-attribution').append(" | "); + $('<a>Embed</a>').attr('href', '#;embed') + .appendTo('.leaflet-control-attribution'); + // Display welcome screen on first launch + var been_here = BusMap.getCookie('been_here'); + if (!(been_here)) { + $("#welcome").show(); + } + BusMap.setCookie('been_here', Date.now()); + // Find the user + that.leaflet.on('locationfound', function(l) { + that.leaflet.fitBounds(l.bounds); + }); + $("#locate a").click(function() { + that.leaflet.locate({ + timeout: 3000, + }); + }); + } else { + // UI stuff for embed mode + $('<a>?</a>').click(function() { showModal('about') }) + .appendTo('.leaflet-control-attribution'); + } }; /* Get Routes (and Stops, and Directions) */ function updateRoutes() { var url = "ajax"; var params = { - dataset: "routes", + query: "routes", agency: that.opts.agency, }; $.getJSON(url, params) @@ -90,22 +133,23 @@ BusMap.Map = function(opts) { function updateVehicles() { var url = "ajax"; var params = { - dataset: "vehicles", + query: "vehicles", agency: that.opts.agency, }; $.getJSON(url, params) .done(function(data) { // Store vehicle locations - that.vehicles = data.locations; + if (!that.vehicles) that.vehicles = []; + for (var v in data.locations) { + that.vehicles[v] = data.locations[v]; + that.vehicles[v].predictions = []; + } // Store predictions for (var s in that.stops) { that.stops[s].predictions = {}; } - for (var v in that.vehicles) { - that.vehicles[v].predictions = []; - } for (var p in data.predictions) { - pr = data.predictions[p]; + var 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)) { @@ -118,7 +162,6 @@ BusMap.Map = function(opts) { that.vehicles[pr.vehicle].predictions.push(pr); } } - that.vehicles = data.locations; updateVehiclesUI(that.vehicles); updateStopsUI(that.stops); }); @@ -162,13 +205,13 @@ BusMap.Map = function(opts) { direction: 'right', clickable: true, }).bindPopup(text + footer, popupOpts).addTo(that.vehicleMarkersGroup); + that.vehicleMarkers[v].label.on('click', function() { + this._source.openPopup(); + }); } else { that.vehicleMarkers[v].setLatLng([vehicles[v].lat, vehicles[v].lon]) .setIconAngle(vehicles[v].heading); } - that.vehicleMarkers[v].label.on('click', function() { - this._source.openPopup(); - }); that.vehicleMarkers[v].bm_updated = Date.now() // Add predictions to the marker popup, if available @@ -176,11 +219,11 @@ BusMap.Map = function(opts) { var predictions = []; var now = new Date(); var offset_mins = now.getTimezoneOffset(); - psorted = vehicles[v].predictions.sort(function(a,b){ + var psorted = vehicles[v].predictions.sort(function(a,b){ return new Date(a.prediction).getTime() - new Date(b.prediction).getTime(); }); - for (p in psorted) { - pr = psorted[p]; + for (var p in psorted) { + var pr = psorted[p]; var p_line = "<strong>" + that.stops[pr.stop_id].title + "</strong>: "; var pdate = new Date(pr.prediction); var diff_sec = (pdate.getTime() - now.getTime()) / 1000; @@ -208,7 +251,7 @@ BusMap.Map = function(opts) { } // Remove stale markes from the map for (v in that.vehicleMarkers) { - var min_updated = Date.now() - (that.vehicleMaxAge * 1000) + var min_updated = Date.now() - (BusMap.vehicleMaxAge * 1000) if (that.vehicleMarkers[v].bm_updated < min_updated) { that.leaflet.removeLayer(that.vehicleMarkers[v]); delete that.vehicleMarkers[v]; @@ -249,18 +292,22 @@ BusMap.Map = function(opts) { title: stops[s].title, icon: markerIcon, opacity: 1, + stopId: s }; that.stopMarkers[s] = L.marker( [stops[s].lat, stops[s].lon], - markerOpts).bindPopup(text, popupOpts); + markerOpts).bindPopup(text, popupOpts) + .on('click', function(e) { + that.selectedStop = this.options.stopId; + }); that.stopMarkersClusterGroup.addLayer(that.stopMarkers[s]); } // Add predictions to the marker popup, if available - if (stops[s].predictions) { + if (stops[s].predictions && that.opts.predictions !== false) { var predictions = []; var now = new Date(); var offset_mins = now.getTimezoneOffset(); - for (r in stops[s].predictions) { + for (var r in stops[s].predictions) { if (!(r in that.routes)) { console.log("Unknown route " + r + " for stop " + stops[s].title); } @@ -270,7 +317,7 @@ BusMap.Map = function(opts) { psorted = stops[s].predictions[r].sort(function(a,b){ return new Date(a.prediction).getTime() - new Date(b.prediction).getTime(); }); - for (p in psorted) { + for (var p in psorted) { pr = psorted[p]; var pdate = new Date(pr.prediction); var diff_sec = (pdate.getTime() - now.getTime()) / 1000; @@ -305,51 +352,135 @@ BusMap.Map = function(opts) { + that.leaflet.getZoom(); return view; } + function applyViewString(view) { + if (!view || view == "") return false; + if (view.charAt(0).toLowerCase() == "s") return _avsStop(view); + window.location.replace("#" + view); + return _avsLatLonZoom(view); + function _avsStop(view) { + if (!that.stopMarkers || !that.stopMarkers[view.substring(1)]) { + return setTimeout(function() { _avsStop(view) }, 500); + } + var marker = that.stopMarkers[view.substring(1)]; + if (marker) { + var ll = marker.getLatLng(); + that.leaflet.setView(ll, BusMap.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(); + } + } + function _avsLatLonZoom(view) { + view = view.split(","); + if (view.length == 3) { + that.leaflet.setView([view[0], view[1]], view[2]); + } else if (view.length == 2) { + that.leaflet.setView([view[0], view[1]]); + } else console.log('Invalid view string: ' + view); + } + } function lastViewRecover() { var last = BusMap.getCookie('last_view'); - if (last && last != "") { - last = last.split(","); - that.leaflet.setView([last[0], last[1]], last[2]); - return true; - } else { - return false; - } + return applyViewString(last); } function lastViewStore() { var view = getViewString(); BusMap.setCookie('last_view', view); } + // Display a modal pop-up + function showModal(name) { + $('div.modal').remove(); + var modal = $('<div class="modal" id="modal-'+name+'"></div>'); + var closeBtn = $('<div class="close">×</div>').appendTo(modal); + closeBtn.on('click', hideModal); + $(that.opts.mapElement).after(modal); + modal.show(); + var params = { + agency: that.opts.agency, + query: "modal", + modal_name: name, + }; + if (modalCache[name]) { + modal.append(modalCache[name]); + setUrlFromView(); + } else { + $.get("ajax", params).done(function(contents) { + modalCache[name] = contents; + modal.append(contents); + setUrlFromView(); + }).fail(function() { + modal.append("Please refresh the page and try again."); + }); + } + } + + // Hide modal popup if open + function hideModal() { + if ($('div.modal').length == 0) return; + $('div.modal').remove(); + setUrlFromView(); + } + // Scaling: update what is displayed based on zoom level function zoomShowHide() { var zoom = that.leaflet.getZoom(); if (that.vehicleMarkersGroup) { - if (zoom >= that.zoomShowVehicles && !(that.leaflet.hasLayer(that.vehicleMarkersGroup))) { + if (zoom >= BusMap.zoomShowVehicles + && !(that.leaflet.hasLayer(that.vehicleMarkersGroup))) { that.leaflet.addLayer(that.vehicleMarkersGroup); - $('#msg-zoomForVehicles').hide(); - } else if (zoom < that.zoomShowVehicles && that.leaflet.hasLayer(that.vehicleMarkersGroup)) { + if (!that.opts.embed) $('#msg-zoomForVehicles').hide(); + } else if (zoom < BusMap.zoomShowVehicles + && that.leaflet.hasLayer(that.vehicleMarkersGroup)) { that.leaflet.removeLayer(that.vehicleMarkersGroup); - $('#msg-zoomForVehicles').show(); + if (!that.opts.embed) $('#msg-zoomForVehicles').show(); } } } - that.setViewFromUrlHash = function() { + function setViewFromUrl() { + if (that.hashChangedProgrammatically ) { + // Ignore hash changes caused programmatically (would inf-loop). + delete that['hashChangedProgrammatically']; + return false; + } + if (that.opts.hashListen == false) { + return false; + } var hash = window.location.hash.substring(1); - hash = hash.split(","); - if (hash.length == 2) { - that.leaflet.setView([hash[0],hash[1]]); - return true; - } else if (hash.length == 3) { - that.leaflet.setView([hash[0],hash[1]], hash[2]); - return true; + if (hash == "") { + return false; + } + var parts = hash.split(";"); + // First part: view string (lat,lon,zoom; vehicle; or stop) + applyViewString(parts[0]); + // Optional second part: name of modal to open (about, welcome, ...) + if (parts.length > 1) { + showModal(parts[1]); } - return false; + return true; } - that.getUrlFromView = function() { + function setUrlFromView() { + if (that.opts.hashUpdate == false) { + return; + } var view = getViewString(); - return window.location.hostname + window.location.pathname + '#' + view; + if ($(".modal").length > 0) { + var modalName = $(".modal").attr('id').split('-')[1]; + view += ";" + modalName; + } + if (window.location.hash == "#" + view) { + // Bail out if old hash == new hash. + // Otherwise, we'll set hashChangedProgrammatically, but + // the change handler will never fire, and it won't be unset. + // TODO: unfuck this bad design before it breaks something else + return; + } + that.hashChangedProgrammatically = true; // avoid infinite loop! + window.location = window.location.href.replace(window.location.hash, "#" + view); } init(); diff --git a/static/js/predictions.js b/static/js/predictions.js new file mode 100644 index 0000000000000000000000000000000000000000..8f85012fdbdbd7e74e815c6c669337bd22b4c1b0 --- /dev/null +++ b/static/js/predictions.js @@ -0,0 +1,159 @@ +BusMap.Predictions = function(opts) { + var stops = {}; + var routes = {}; + var that = this.Predictions; + that.opts = opts; + window._buspredictions = this.Predictions; + + /* 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.predictions) { + setInterval(updatePredictions, that.opts.refresh.predictions * 1000); + } + + // Listen for hash change + if (that.opts.hashListen !== false) { + $(window).on('hashchange', setViewFromUrl); + } + + // Grab initial URL + setViewFromUrl(); + + }; + + /* 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); + }); + }; + + /* Get Vehicles (and Predictions) */ + function updatePredictions() { + if (!that.stops) { + updateRoutes(); + return setTimeout(function() { updatePredictions() }, 500); + } + var url = "ajax"; + var params = { + query: "predictions", + stops: that.stop, + 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) { + var 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); + } + } + updatePredictionsUI(that.stops); + }); + }; + + function applyViewString(view) { + if (!view || view == "") return false; + if (!that.stops) { + updateRoutes(); + return setTimeout(function() { applyViewString(view) }, 500); + } + if (view.charAt(0).toLowerCase() == "s") return _avsStop(view); + function _avsStop(view) { + var stop = that.stops[view.substring(1)]; + if (stop) { + that.stop = stop.id; + // Show this stop + $(that.opts.predictionsElement).html( + "<header>" + stop.title + "</header>" + + "<p></p>"); + updatePredictions(); + } else console.log('Unknown stop in view string: ' + view.substring(1)); + } + } + + function setViewFromUrl() { + var hash = window.location.hash.substring(1); + if (hash == "") { + $(that.opts.predictionsElement).html("No stop selected"); + } else applyViewString(hash); + } + + /* Refresh (and/or create) UI elements for Stops */ + function updateStopsUI(stops) { + var hash = window.location.hash.substring(1); + if (hash == "") return false; + applyViewString(hash); + } + + /* Refresh (and/or create) UI elements for Predictions */ + function updatePredictionsUI(stops) { + if (!that.stop) return false; + var s = that.stop; + if (stops[s].predictions) { + var predictions = []; + var now = new Date(); + var offset_mins = now.getTimezoneOffset(); + for (var 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 + var psorted = stops[s].predictions[r].sort(function(a,b){ + return new Date(a.prediction).getTime() - new Date(b.prediction).getTime(); + }); + for (var p in psorted) { + var 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) { + var predictions = ['<span class="none">No arrival predictions.</span>']; + } + var text = '<section class="predictions stop-predictions">' + + predictions.sort().join("<br>") + '</section>'; + $(that.opts.predictionsElement).find('p').html(text); + } + } + + init(); + return that; +} diff --git a/templates/base.html b/templates/base.html index a7994e45eeaeb938741ab3cdd24bb4ed50fe5429..212512c41da22a590847ce7791cdd1f6f151d6b1 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,7 +7,7 @@ {% block head %} {% endblock %} </head> -<body> +<body class="{% if embed %}embed{% endif %}"> {% block body %}{% endblock %} {% block body_end %}{% endblock %} </body> diff --git a/templates/combined.html b/templates/combined.html new file mode 100644 index 0000000000000000000000000000000000000000..ea2f4e7c708033acdf1ac675f04f4e7ac8045117 --- /dev/null +++ b/templates/combined.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title -%} + {% if agency.short_title %}{{ agency.short_title -}} + {% elif agency.title %}{{ agency.title }}{% endif %} Bus Map +{%- endblock %} +{% block head %} + {{ super() }} + <link href="bower/leaflet/dist/leaflet.css" rel="stylesheet"/> + <link href="bower/leaflet.markercluster/dist/MarkerCluster.Default.css" rel="stylesheet"/> + <link href="bower/Leaflet.label/dist/leaflet.label.css" rel="stylesheet"/> + <link href="static/css/map.css" rel="stylesheet" /> + <link href="static/css/predictions.css" rel="stylesheet" /> + <link href="static/css/combined.css" rel="stylesheet" /> +{% endblock %} +{% block body %} + <div class="combined map{% if embed %} embed{% endif %}" id="c-map"> + </div> + <div class="combined predictions" id="c-predictions"> + </div> + <script src="bower/leaflet/dist/leaflet.js"></script> + <script src="bower/Leaflet.label/dist/leaflet.label.js"></script> + <script src="bower/leaflet.markercluster/dist/leaflet.markercluster.js"></script> + <script src="bower/leaflet-marker-rotate/leaflet.marker.rotate.js"></script> + <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 map = BusMap.Map({ + {% if embed %}embed: true,{% endif %} + agency: {{ agency.tag|tojson|safe }}, + mapElement: $("#c-map").get(0), + tileUrl: '{{ config['MAP_TILE_URL']|safe }}', + tileOptions: { + subdomains: {{ config['MAP_TILE_SUBDOMAINS']|tojson|safe }}, + tileset: '{{ config['MAP_TILESET']|safe }}', + errorTileUrl: '{{ config['MAP_ERROR_TILE_URL']|safe }}', + }, + bounds: [ + [{{ (agency.lat_min - config['MAP_LAT_PADDING'])|round(5) }}, + {{ (agency.lon_min - config['MAP_LON_PADDING'])|round(5) }}], + [{{ (agency.lat_max + config['MAP_LAT_PADDING'])|round(5) }}, + {{ (agency.lon_max + config['MAP_LON_PADDING'])|round(5) }}] + ], + refresh: { + routes: 60, + vehicles: 5, + }, + hashUpdate: false, + predictions: false, + }); + var predictions = BusMap.Predictions({ + agency: {{ agency.tag|tojson|safe }}, + predictionsElement: $("#c-predictions").get(0), + refresh: { + routes: 60, + predictions: 10, + }, + hashListen: false, + }); + </script> +{% endblock %} diff --git a/templates/map.html b/templates/map.html index f3c95ee8e43fff02c4db5abc8a9700cf44240bd3..c4f070413246695dbd831d68b0d967bd05e47510 100644 --- a/templates/map.html +++ b/templates/map.html @@ -11,15 +11,8 @@ <link href="static/css/map.css" rel="stylesheet" /> {% endblock %} {% block body %} - <div id="map" class="{% if embed %}embed{% endif %}"></div> - <div class="dialog" id="about"> - <div class="close" id="close-about"><a href="javascript:void(0);">×</a></div> - {% include "about.html" %} - </div> - <div class="dialog" id="welcome"> - <div class="close" id="close-welcome"><a href="javascript:void(0);">×</a></div> - {% include "welcome.html" %} - </div> + <div class="map{% if embed %} embed{% endif %}" id="map"></div> + {# TODO: Generate these using JS, not here. #} <div id="msg"> <span id="msg-zoomForVehicles">Zoom in to see vehicles.</span> </div> @@ -55,30 +48,5 @@ vehicles: 5, }, }); - {% if not embed %} - // Put "About" link into the attribution box - $(".leaflet-control-attribution") - .html('<a id="show-about" href="javascript:void(0)">About</a>'); - $("#show-about").click(function() { $("#about").show(); }); - $(".dialog .close").click(function() { $(this).parent().hide(); }); - - // Display welcome screen on first launch - var been_here = BusMap.getCookie('been_here'); - if (!(been_here)) { - $("#welcome").show(); - } - BusMap.setCookie('been_here', Date.now()); - - // Find the user - map.leaflet.on('locationfound', function(l) { - map.leaflet.fitBounds(l.bounds); - }); - $("#locate a").click(function() { - map.leaflet.locate({ - timeout: 3000, - }); - }); - {% endif %} - </script> {% endblock %} diff --git a/templates/about.html b/templates/modal-about.html similarity index 70% rename from templates/about.html rename to templates/modal-about.html index add748a655774d142fab1ecb4418ef9b0aa3e22d..b5e7ce02e9907d1577143de64133503222b3c0ef 100644 --- a/templates/about.html +++ b/templates/modal-about.html @@ -1,8 +1,12 @@ -<h2>About PyBusMap</h2> +<h2>About This Map</h2> <p> This map displays real-time vehicle locations and arrival predictions for public transit systems which are tracked by NextBus. </p> +<p class="embed-only" style="text-align: center; font-size: 1.2em;"> + <a href="{{ url_for('map', _external=True) }}" target="_blank"> + Click to see the full map</a> +</p> <p> Built by <a href="https://ant.sr">Anton Sarukhanov</a>. Open source and MIT licensed. @@ -26,6 +30,6 @@ NextBus™ is a trademark of NextBus Inc. </p> <p class="legalese"> - Not guaranteed to be correct. - Refer to your travel agency for official schedules and/or predictions. + Data not guaranteed to be correct. + Refer to your transit agency for official schedules and/or predictions. </p> diff --git a/templates/modal-embed.html b/templates/modal-embed.html new file mode 100644 index 0000000000000000000000000000000000000000..d588e59e3ac729110654bf819959eafbb8937e35 --- /dev/null +++ b/templates/modal-embed.html @@ -0,0 +1,78 @@ +<h2>Embed + {% if agency.short_title %}{{ agency.short_title -}} + {% elif agency.title %}{{ agency.title }}{% endif %} Bus Map +</h2> +<p> + Use maps and stop predictions in your website or digital signage. +</p> +<h3>Embed Code</h3> +<textarea id="embed-code" cols=100 rows=3 readonly=true></textarea> +<h3>Preview</h3> +<div id="embed-preview"> + <iframe + src="" + frameborder=0></iframe> +</div> +<h3>Options</h3> +<form id="embed-form"> + <p> + <label for="embed-mode"> + <span>Mode:</span> + <select id="embed-mode"> + <option value="m">Map</option> + <option value="p">Predictions</option> + <option selected 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> + <label for="embed-width"> + <span>Width:</span> + <input id="embed-width" type="number" min=200 step=50 value=500> px + </label> + <br> + <label for="embed-height"> + <span>Height:</span> + <input id="embed-height" type="number" min=200 step=50 value=250> px + </label> + </fieldset> + </p> + <p> + <label for="embed-responsive"> + <span>Responsive:</span> + <input id="embed-responsive" type="checkbox" checked> + <small>Resize automatically to fit a smaller window or device.</small> + </label> + </p> +</form> +<hr> +<p class="legalese"> + Data not guaranteed to be correct. + Refer to your transit agency for official schedules and/or predictions. +</p> +<p class="legalese"> + No guarantees are made about availability, performance, or suitability of this service for any purpose. +</p> +<script> + $.getScript('static/js/embed.js', function() { + BusMap.Embed({ + baseUrl: "{{ url_for('map_embed', mode='', _external=True, _scheme='') }}", + }); + }); +</script> diff --git a/templates/welcome.html b/templates/modal-welcome.html similarity index 88% rename from templates/welcome.html rename to templates/modal-welcome.html index 4740d157ce30019a9836bb25a85552f4e4db0126..44da60a6f609990c9214bc12676ec730874b3b53 100644 --- a/templates/welcome.html +++ b/templates/modal-welcome.html @@ -14,5 +14,5 @@ </h1> <p class="legalese"> Not guaranteed to be correct. - Refer to your travel agency for official schedules and/or predictions. + Refer to your transit agency for official schedules and/or predictions. </p> diff --git a/templates/predictions.html b/templates/predictions.html new file mode 100644 index 0000000000000000000000000000000000000000..162a54b3889068aaf976af154d89d569739be03e --- /dev/null +++ b/templates/predictions.html @@ -0,0 +1,25 @@ +{% 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 class="predictions" 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: { + routes: 60, + predictions: 10, + } + }); + </script> +{% endblock %}