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("&nbsp;|&nbsp;");
+            $('<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">&times;</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);">&times;</a></div>
-        {% include "about.html" %}
-    </div>
-    <div class="dialog" id="welcome">
-        <div class="close" id="close-welcome"><a href="javascript:void(0);">&times;</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&trade; 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 %}