From 972c8a2268c270d8b603ec7bf1883add5324ae91 Mon Sep 17 00:00:00 2001
From: Anton Sarukhanov <code@ant.sr>
Date: Thu, 14 Apr 2016 23:39:13 -0400
Subject: [PATCH] Embed UI (and related refactoring) completed.

---
 app.py                     |  32 +++++++++-
 static/css/predictions.css |   5 ++
 static/js/embed.js         | 127 ++++++++++++++++++++++++++----------
 static/js/map.js           |  60 +++++++++++------
 static/js/predictions.js   | 128 +++++++++++++++++++++++++++++++++++++
 templates/modal-embed.html |  34 ++++++++--
 templates/predictions.html |  22 +++++++
 7 files changed, 346 insertions(+), 62 deletions(-)
 create mode 100644 static/css/predictions.css
 create mode 100644 static/js/predictions.js
 create mode 100644 templates/predictions.html

diff --git a/app.py b/app.py
index a8e4057..e52923e 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 0000000..5c9174a
--- /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 7107a0e..69a80a8 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 979a20a..607a20d 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("&nbsp;|&nbsp;");
-            $('<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">&times;</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 0000000..5998757
--- /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 9e7aa4b..312a8c6 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 0000000..ad582e3
--- /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 %}
-- 
GitLab