Adding maps to AppBuilder applications

Author: Dave Cassel  |  Category: Software Development

Using MarkLogic’s Application Builder makes it a snap to put together an application quickly. Whenever I build an app this way, I feel like I’ve gotten 80% of what I need. This post shows how to take the next step and add a common feature: maps.

To illustrate these steps, I’m going to use an application I put together to track my souvenir pin collection. I wanted to keep track of when and where I got each of the pins, and a map is a natural way to display that. At a high level, here are the steps:

  1. Put some sample data in the database
  2. Use Application Builder to generate the basic application
  3. Add an element where the map will appear
  4. Create a module to return some data points
  5. Write JavaScript to make the display
  6. Include the necessary JavaScript files

Now for the details:

1. Put some sample data in the database

I decided to go ahead and put my pin collection out there in the wild, so that you’ll have something concrete to use to work through this tutorial, if you have nothing better. The structure is nothing special, but it will do the job. Set up your database and load up the data. The easiest way is with MarkLogic Server 4.2’s new Information Studio (which you can learn about from a couple docs on developer.marklogic.com). I put an date element range index on acquired-date so that I could have a facet, but it’s not required for this exercise.

2. Use Application Builder

As with Information Studio, I’m just going to give you a quick reference to the learning page on MarkLogic’s developer site, since I want to focus this tutorial on the map aspects. You can take all the defaults while going through the App Builder wizard. When you’re done this step, you should have a standard, out-of-the-box app where you can nose around my pin collection. If you search for “bell”, you should find my Liberty Bell pin.

3. Add an element where the map will appear

Application Builder puts the generated code into a modules database. You can either use WebDAV to edit it there, or use the Support Package to get a copy to put on the file system and edit that way (in App Builder, click the down arrow next to the application’s name and choose Support Package from the drop menu; make sure you update the application server to point to the directory on the filesystem where you unzip). Either way, we’ll be working mostly in the application/custom/ directory.

Our goal here is to put the map on the first page we see. In an App Builder app, pages are identified by the “view”. Open up application/custom/appfunctions.xqy and find the app:get-content() function. The first thing you’ll notice is probably that it is commented out. When App Builder generates an app, it produces standard and modifiable copies of a bunch of functions that control how the app runs. Close the comment that precedes the function and remove the close-comment marker at the end of it so that this copy of the function will be in effect.

You’ll notice this line at the top of the function:

let $view := $config:CONTEXT/*:view

This identifies the view. We want to add a div element to house the map on the “intro” view. Here’s how the function ends up:

declare function app:get-content()
{
    let $view := $config:CONTEXT/*:view
    return
        if ($view eq "search")
        then (
            xdmp:apply($config:toolbar),
            if (data($config:RESPONSE/@total) eq 0)
            then xdmp:apply($config:error-message, concat("Your search for ",$config:CONTEXT/*:q," did not match anything. Make sure all words are spelled correctly or try different keywords."))
            else (
                xdmp:apply($config:result-navigation),
                xdmp:apply($config:results),
                xdmp:apply($config:result-navigation)))
            else if ($view eq "detail")
            then (
                xdmp:apply($config:toolbar),
                xdmp:apply($config:item-render)  )
            else if ($view eq "terms")
            then xdmp:apply($config:terms)
            else if ($view eq "help")
            then xdmp:apply($config:help)
            else if ($view eq "contact")
            then xdmp:apply($config:contact)
            else if ($view eq "intro") then
                <div id="map" style="width:100%; height:300px"/>
            else xdmp:apply($config:browse)
};

That gives us a place to put our map. Now we need to get some data for it.

4. Create a module to return some data points

This XQuery code retrieves the geo points in the database, along with the pin’s URI and name. (The where clause is necessary, because when a pin is added, there might be a description of where it came from without having lat & long.) Save the following to /application/custom/get-points.xqy:

xquery version "1.0-ml";
<locations>
{
    let $pins := cts:search(/pin, cts:element-query(xs:QName("location"), cts:and-query(())))
    for $pin in $pins
    where fn:exists($pin/location/lat)
    return
        <location>
            <url>/detail{fn:base-uri($pin)}</url>
            <name>{$pin/name/text()}</name>
            <lat>{$pin/location/lat/text()}</lat>
            <long>{$pin/location/long/text()}</long>
        </location>
}</locations>

Note that this isn’t anything fancy — it’s grabbing all the points. For an application with more data, you’d probably want to restrict the results to the area showing on the map, and perhaps by other criteria. To run good geo queries, you’ll want to add a geospatial index. We’ll leave that for another day and just grab all the data for now.

5. Write JavaScript code to display the points

I created an /application/custom/js/maps.js file that handles initialization and display of points. Let’s take a look at that, and then we’ll bring it all together by adding the script include statements that we need.

function initialize() {
    var latlng = new google.maps.LatLng(-34.397, 150.644);
    var myOptions = {
        zoom: 8,
        center: latlng,
        mapTypeId: google.maps.MapTypeId.ROADMAP
    };
    var map = new google.maps.Map(document.getElementById("map"),
        myOptions);

    loadPoints(map);
}

$(document).ready(function() {
    initialize();
});

function buildMarkerClickEventHandler(marker, href) {
    google.maps.event.addListener(marker, 'click', function() {
        window.location = href;
    });
};

function loadPoints(map) {
    $.ajax({
        url: "/custom/get-points.xqy",
        contentType: "xml",
        success: function(data) {
            var bounds = new google.maps.LatLngBounds();
            $(data).find("location").each(function(index) {
                var lat = $(this).find("lat").text();
                var long =  $(this).find("long").text();
                var marker = new google.maps.Marker({
                    position: new google.maps.LatLng(lat, long),
                    map: map,
                    title: $(this).find("name").text()
                });
                buildMarkerClickEventHandler(marker, $(this).find("url").text());
                bounds.extend(new google.maps.LatLng(lat, long));
            });
            map.fitBounds(bounds);
        }
    });
};

We have an initialize() function that gets called when the page is ready. The initialize() function creates a map in the div element that we created to hold the map in step 3. It then calls loadPoints(), which makes an AJAX call back to the module we created in step 4. The results come back as XML. I use jQuery to parse through the results, create a set of markers, and figure out the correct bounds for the map so that it is zoomed just right to display our data. If you click on one of the markers, that will take you to the detail page for the individual pin (take another look at the get-points.xqy module and you’ll see that set up in the url element).

You may be looking at the code wondering why I made a buildMarkerClickEventHandler() function instead of just creating the listener inline within loadPoints(). In a word, closure. To keep this post focused, I’ll leave a more detailed explanation for another day (if you’re dying to know, say so in the comments, that will make me get to it faster).

You’ll also need to download jQuery, as I’m using that in my script. Put it in /application/custom/js/.

6. Include the necessary JavaScript files

Now to bring it all together. We have the pieces; what’s missing is that they aren’t being used yet. To add the JavaScript includes, let’s return to /custom/appfunctions.xqy and take a look at app:js(). Remove the comment markers so that this function will get used. In this function we can add more includes. We can make them across-the-board or specific to a view. There are three JavaScript files we need: jQuery, Google Maps, and our own maps.js. jQuery is something that will likely get used a lot (at least the way I write, so I add that to the list of includes at the top of the function. After that, we’ll add the Google maps include and maps.js, but only on the intro view. Here’s how the function looks when we’re done:

declare function app:js()
    as element()*
{
    (<script src="/yui/yahoo-dom-event/yahoo-dom-event.js" type="text/javascript"><!-- --></script>,
     <script src="/yui/container/container_core-min.js" type="text/javascript"><!-- --></script>,
     <script src="/yui/menu/menu-min.js" type="text/javascript"><!-- --></script>,
     <script src="/yui/animation/animation-min.js" type="text/javascript"><!-- --></script>,
     <script src="/yui/datasource/datasource-min.js" type="text/javascript" ><!-- --></script>,
     <script src="/yui/connection/connection-min.js" type="text/javascript"><!-- --></script> ,
     <script src="/yui/autocomplete/autocomplete-min.js" type="text/javascript" ><!-- --></script>,
     <script src="/js/application.js" type="text/javascript"><!-- --></script>,
     (: add this (your jquery file may be a different version) ... :)
     <script type="text/javascript" src="/custom/js/jquery-1.4.2.min.js"></script>,
     $config:ADDITIONAL-JS,
     (: and this... :)
     if ($config:CONTEXT/*:view = ("intro")) then (
        <script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>,
        <script type="text/javascript" src="/custom/js/maps.js"></script>)
     else (),
     <script type="text/javascript" charset="utf-8">
        // Sort menu
        {
        if (count($config:OPTIONS/search:operator[@name eq "sort"]/search:state) > 0)
        then concat(
            'var sort_menu_content = [',
            xdmp:apply($config:sort-menu-content,$config:OPTIONS,$config:CONTEXT/*:q,$config:LABELS),
            ']')
        else ()
        }
        // Category toggle
        var toggle_list_size = [
            { xdmp:apply($config:facet-toggle-content,$config:OPTIONS) }
        ]
        new ListToggler(toggle_list_size);
    </script>)
};

The inline comments mark what we’re adding. Remember to un-comment the function by moving the close-comment marker from the end to before the function, just after the preceding comment.

So there you have it. Follow these steps, and you should find yourself with a working map display. Leave me a comment to tell me where more detail is needed or what cool stuff you’ve found to display on your maps. Happy coding!

Tags: , , , ,

9 Responses to “Adding maps to AppBuilder applications”

  1. Randy Smith Says:

    Dave,
    In the section where you talk about the xQuery i.e. “Create a module to return some data points”, should this be a separate .xqy file under the “custom” directory or should it be part of one of the other modules?
    Randy

  2. Dave Cassel Says:

    Hi Randy. Yes, that would be a separate .xqy file under custom/.

  3. Randy Smith Says:

    Dave,
    I think I have all the code in ok, but when I go to the web page I still get the original App Builder app I created in the beginning to search the “pin” data. I assume it should now bring up the Google Maps view. I moved the support package to a folder and added it to an Eclipse .xqy project. I then added the path to my Eclipse code to the DB where I put the “pin” data and selected “file system”. Do I need to change some CSS file or something? Any suggestions.
    Thanks, Randy

  4. Dave Cassel Says:

    Randy,

    The most likely problem is not having un-commented one of the functions in appfunctions.xqy. Remember that those functions are copies of the functions in standard.xqy. The ones in appfunctions.xqy are initially commented out, so move the close-comment marker from the end of the function to the end of the comment just before the function.

    I’ve updated the post to clarify that point and a couple others.

  5. Randy Smith Says:

    Dave,
    SUCCESS! I needed jQuery downloaded. Works great. Now for the next step!
    Thanks!

  6. David Steiner Says:

    I had no trouble getting this working with my data – easy to follow.
    However, I have hundreds of thousands of “points”.
    So, I’d like to show the map on the results page (not the intro), with the 10 results that are listed as being the points (though, I’d like to up that to 25 or 50).
    I’m able to show the map on the results page by taking out the “intro” condition and placing the div for the map in the get-content section where the results appear to be built, but now I’ve got to figure out how to get the 10 results “passed” to the get-points.xqy, or whatever the loadPoints in map.js is going to call.
    When someone scrolls to the next results page, it would be a different 10 results and thus, 10 new “points”, so it would seem that I should place the map building functionality in app:results, which is where the results are created. However, I would have to return 2 “div” elements, not one and I’d still have to pass the results to the java script so that they could be passed to get-points.xqy?
    Am I even close???

  7. Dave Cassel Says:

    Hi David,

    Sounds like you’re well on the way. The way I’ve done that is to pass the query to the get-points.xqy module, which can then return just the relevant points. Steps:

    * Get the query into Javascript. You can do this in appfunctions.xqy app:js(). You can access the current query as $config:CONTEXT/*:q.
    * Send the query along with the AJAX request. Make this change in maps.js.
    * Modify get-points.xqy to work in the query.

    This is the asynch way to do it. It can also be done synchronously, thereby only running the query once. That would be a different approach that the one taken in this tutorial. I like the asynch approach, because that way you can set up the get-points.xqy module to respond to the user shifting or zooming the map.

    Let me know how you do. If you get stuck I’ll give more specifics.

    Dave.

  8. David Steiner Says:

    Hi Dave,

    Thanks for the tips. However, if I understand what you’ve said, then I actually have to run the search again and produce the results the same results that are in the current results list. Aside from figuring out what the app does to exectute the search (unless it’s just search:search), I’d have to figure out which results page the user is on: 1 – 10, 11 – 20, etc..
    Thus, I think that might be more complex than simply passing the results to the javascript and from there to the get-points xquery.
    I was actually able to accomplish this by modifying the app:js routine to 1) access the xdmp:apply($config:results), 2) create a string out of the id attributes for each result, then 3) encoding that string as a URI component and putting it in a js variable.
    I modified maps.js to pass that variable to the get-points.xqy and change get-points.xqy to just read that string to establish the locations.

    I am trying to do something similar to google maps where you have the results and the map next to each other with a label on the result and a that label on the marker on the map.

    My next step is to figure out how to get a label into the result details and get that label into the marker as well.

    I also want to pull more data from documents besides lat/long, etc., and create a infoWindow when the user clicks on the marker, but that’s getting a bit beyond the topic, perhaps.

    Let me know if you see a problem with this method and what would be better to accomplish what I want – I really don’t know javascript and html very well, so I’m sure I may not be using a proper technique, even though it works…

    Thanks,
    David

  9. Dave Cassel Says:

    Hi David,

    Yes, the method I’ve laid out is asynchronous, as does require running the search again. That allows some fancy interactions with the map, but doesn’t seem to be the case you’re working on.

    If I were taking the synchronous approach, what I would do is use XQuery to print the results as JavaScript — presenting it as an XML string would work fine. So in XQuery, you iterate through the query results, pull out the locations, and print them inside of a &lt;script&gt; tag. From there maps.js would be similar to what it has now, except that instead of processing the results of an AJAX call, it’s working on a variable you set up in app:js().

    You can customize the way the search results look either by working through App Builder’s Results tab or by using XSLT in apptransform-abstract-metadata.xsl. To put more information into your map markers, I’ll cheap and refer you to the Google Maps API for now. :) You can do some neat stuff that way, as long as the data is available. For instance, include the URI, and you can set up clicking on a marker to take you to an item’s detail page.

    Let me know if that’s not clear. Maybe there’s another tutorial that needs to be written — or at least, part 2 of this one.

Leave a Reply