Re-thinking maps in webdrawer

Webdrawer is one possible implementation of a public records portal.  It does its' job and it does it well.  However, it has the distinct disadvantage of having already been built.  I feel like I'm forcing a square map down a round webdrawer (or something like that).  And it doesn't have to be "my way or the highway" with Content Manager! 

First I need to list what I didn't like about my Webdrawer Maps:

  • My map only showed the current page of search results
  • Moving to the next page of search results requires a complete reload of the page 
  • Using the quick search results in a complete reload of the page 
  • Reloading of the page results in loss of other facilities in the map
  • Scrolling around on my map didn't show me other facilities

So I decided to play with something newish: the ServiceAPI.

Here's what I ended up with as a prototype:

2017-09-28_7-52-26.png

Features of this page include:

  1. One page -- the design uses one page that "wires together" the ServiceAPI with Google Maps via jquery
  2. User interface events -- buttons, links, and controls provide interactive experience 
  3. Facilities data -- the list of facilities projected onto the map as markers.  
  4. Facility Info Cards -- clicking a map marker or the "center" link provides the user with a pop-up information window, a distance circle from the marker, and an additional set of search links.  Clicking a search link run a records search within the circle.  

In this post I'll break down the current design and explain how (and why) it was created.


One page

The layout I want is straightforward: I want a map, a search area, and a list of search results.  If I do everything else via jquery then I'll have the responsive design I want.  I've color coded my regions so that you can get an idea what I'm thinking.

Visual styling is intentionally rudimentary since this is not a "real project".

Visual styling is intentionally rudimentary since this is not a "real project".

 
 
  • Yellow -- Search controls
  • Purple -- Loading/busy message
  • Blue -- Search results
  • Pink -- Map coordinates
  • Gray -- Banner

Now I can model this out within a single HTML file, like shown below...

<!DOCTYPE html>
<html>
 
<head>
    <meta name="viewport" content="initial-scale=1.0, user=scalable=no" />
 
    <link href="css/map.css" type="text/css" rel="stylesheet" />
    <!-- Access Google Maps API -->
    <script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?key=donteventhinkaboutit"></script>
    <script type="text/javascript" src="scripts/jquery-3.2.1.js"></script>
    <script type="text/javascript" src="scripts/map.js"></script>
    <script type="text/javascript" src="scripts/cm.js"></script>
    <script type="text/javascript" src="scripts/ui.js"></script>
 
    <!-- Latest compiled and minified CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script type="text/javascript">
 
        $(document).ready(function () {
            initializePage();
        });
    </script>
</head>
 
<body>
    <table id="container">
        <tr>
            <td colspan="2" height=30 style="background-color:gray">
                Content Manager Ramble -- ServiceAPI integration with Google Maps
            </td>
        </tr>
        <tr>
            <td width="70%">
                <div id="mapDiv"></div>
                <div id="coordsDiv"></div>
            </td>
            <td>
                <table style="height:100%width100%">
                    <tr height="100">
                        <td align=center valign="middle">
                            <div id="searchDiv">
                                <span>Title/Keyword&nbsp;</span><input id="keywordInput" type="text" /><br><Br><input type=submit id='searchButton' value=Search /><input type="button" id="loadVisibleFacilities" value="Visible in Map" />
                            </div>
                            <div id="errorDiv"></div>
                        </td>
                    </tr>
                    <tr>
                        <td align=center rowspan=2>
                            <div id="loadingDiv"><span class="label label-danger">Danger...Loading!</span></div>
                            <div id="resultsDiv"></div>
                            <div id="notFoundDiv"></div>
                        </td>
                    </tr>
                </table>
            </td>
        </tr>
    </table>
</body>
</html>

That's it!  This HTML file lays out the various elements of my user interface in the form of "<div>"'s. 


User Interface Events

In my previous post I showed how to plot facilities onto a map as the webdrawer page loads.  I want to do something along the same lines here.  Though here I'm going to use the jquery library (not the google maps API) to add some event listeners to the page.

I can think of several events I want to trigger upon:

  1. Load -- once the page is loaded, go fetch all the facilities, mark them on the page, and show the list in the search results page
  2. Click marker -- zoom/center onto the marker and show an information window for the related facility
  3. Click search -- filter the list of facilities to just those included in the search results
  4. Click visible facilities -- filter the list of facilities to just those included in the bounds of the map

If you look back at the HTML code at the top of this post you'll see the javascript code that kicks this whole process off.

 
<script type="text/javascript" src="scripts/jquery-3.2.1.js"></script>
<script type="text/javascript" src="scripts/map.js"></script>
<script type="text/javascript" src="scripts/cm.js"></script>
<script type="text/javascript" src="scripts/ui.js"></script>
<script type="text/javascript">
 
    $(document).ready(function () {
        initializeUI();
    });
</script>

The code above imports a few javascript files I've created and tells the browser to execute "initializeUI" once the document is ready (loaded).  I've included my initialize function below, so that you can see how I've implemented each.

 
function initializeUI() {
    // hide the loading message (since nothing is loading right now)
    var $loading = $('#loadingDiv').hide();
    // when ajax started: tell user via message; when it stops: hide that message
    $(document)
        .ajaxStart(function () {
            $loading.show();
        })
        .ajaxStop(function () {
            $loading.hide();
        });
    // fetch facilities with default search
    loadFacilities();
    // clicking search should apply current search query and fetch
    var searchButton = $('#searchButton').click(function () {
        loadFacilities();
    });
    // clicking visible facilities should filter result list to map bounds
    var loadVisibleFacilities = $('#loadVisibleFacilities').click(function () {
        for (i = 0; i < facilities.length; i++) {
            var mm = facilities[i].mapmarker;
            facilities[i].showInResults = false;
            if (mm != null) {
                var markerpos = mm.getPosition();
                if (map.getBounds().contains(markerpos)) {
                    facilities[i].showInResults = true;
                }
            }
        }
        refreshFacilityList();
    });
}

Facility Data

Now that my design is a one-page HTML file, I need to figure out how to get my data. 

Webdrawer is still a viable option.  I could craft a search string and pass it along into the standard "Record?q=mysearchstringgoeshere" URL.  I could even tack on "&format=json" to get my results without the UI.

 
Webdrawer results page with UI

Webdrawer results page with UI

 
Webdrawer results page without UI

Webdrawer results page without UI

There are a few things I don't like about this approach:

  • It exposes meta-data fields unnecessarily
  • It lets any person see how to manipulate the URL so that different data might be returned

So instead of continuing to add onto webdrawer, I moved it into a separate site called "o1" (option 1).  I then created a new site named "o2" (option 2), which is a duplicate copy of the CM ServiceAPI.  Then I adjusted the hptrim.config file in o2 so that I have routes for "Map", "Facilities", and "Facility".

2017-09-28_9-37-19.png

This means I don't have to worry about someone altering the URL and exfiltrating records I don't want them to have. 

Altering the URL does not expose the document I created today

Altering the URL does not expose the document I created today

It also means my user interface can retrieve data from the "/Facilities?format=json" URL

2017-09-28_9-39-24.png

Great, so now I've got a single web-page partitioned into a few user interface elements.  I've also got a Json data service exposing just a list of facililties, and only the fields I need for my user interface.  Time to wire it up!


Ajax loading

Now that I have my one-page user interface "wired-up" to do things, I need to tell it how to actually do those things.  First things first.... I need to load my facilities.  To accomplish that I'll craft a query string (based on the current state of the user interface), disable the search controls, and initiate an ajax call.

function loadFacilities() {
    qs = generateQuery();
    if (isEmpty(qs)) {
        enableSearch();
    } else {
        disableSearch();
        ajaxActive = true;
        $.ajax({
            url: 'http://wg1/o2/Facilities',
            type: 'GET',
            data: { q: qs, format: 'json', pagesize: 5000, start: 0 },
            contentType: 'text/html; charset=utf-8',
            success: function (returnData) {
                updateResults(returnData);
            },
            error: function (err) {
                displayError(err.statusText);
            },
        }).always(function (jq, ts) {
            if (ts != "success") {
                displayError(jq.statusText);
            }
            enableSearch();
        });
    }
}

In the above code I fire-off a request to my new "o2/Facilities" data service (provided by the ServiceAPI).  When I get a successful response I "updateResults".  When I get an error I "displayError".  Either way, I re-enable the search interface once completed.

Within this implementation I want to place all facilities on the map, but only selectively display them in the search results list.  When a user searches, I will toggle the display in the list (or on the map) based upon the later results.  By taking this approach, I reduce the constant loading/unloading of facility information and improve the overall user experience.

for (i = 0; i < facilities.length; i++) {
    facilities[i].showInResults = false;
}
        // import list of facilities matching search criteria
$.each(obj.Results, function (key) {
    var rec = obj.Results[key];
    var found = false;
    for (i = 0; i < facilities.length; i++) {
        if (facilities[i].uri == rec.Uri) {
            facilities[i].showInResults = true;
            found = true;
            break;
        }
    }
    if (!found) {
        var facility = { "uri": rec.Uri, "number": rec.RecordNumber.Value, "title": rec.RecordTitle.Value, "latlng": rec.RecordGpsLocation.Value, "mapmarker"null, showInResults: true };
        facilities.push(facility);
        addMapMarker(facility);
    }
});

 

That's it!  Now if the user clicks "search", the ajax call will get back a list of facilties matching the new search query.  The function above will "hide" all facilities from the search results and "show" only those in the results.  I like this approach because it means I can manipulate the map for items not included in the search results.  I'll need that capability when I start adding map layers.


That's it!  Now I've got a fully functional single HTML page that can interact with both the ServiceAPI and Google Maps.  More importantly, it doesn't expose any data out via webdrawer.  Lastly, I'm now prepared to implement my "search within this circle" concept via the map.

Adding a map to Webdrawer

My out-of-the-box webdrawer interface lacks a map for the results!  Here's what it looks like right now....

2017-09-25_10-20-37.png

Let's fix it!


If I open the results list partial class file in the "/Views/Shared" directory, I can partition the page into two columns.

 
<table id="container" margin="5">
    <thead>
        <tr>
            <td width="50%"><span style="margin-left:10px">Results List</span></td>
            <td width="50%">Results Map</td>
        </tr>
    </thead>
    <tbody>
    <td id="listColumn">
            <!-- Search Results go here -->
    </td>
    <td id="mapColumn"><div id="mapDiv"></div></td>
    </tbody>
</table>

Next we import the google maps API

 
<script type="text/javascript" src="https://maps.googleapis.com/maps/api/js?key=123">
</script>

But I also need to add some code that results in the map being loaded...

 
		<script type="text/javascript">
			var map;
			var markers = [];
			var mapmarkers = [];
			function initialize() {
				var mapOptions = {
					center: new google.maps.LatLng(27.897349,-82.155762),
					zoom: 7,
					disableDefaultUI: true,
					zoomControl: true,
					panControl: true,
					mapTypeControl: true,
					scaleControl: true,
					streetViewControl: true,
					rotateControl: true,
					overviewMapControl: true
				};
 
				map = new google.maps.Map(document.getElementById("mapDiv"), mapOptions);
				addMarkers(map);
			}
            google.maps.event.addDomListener(window, "load", initialize);
		</script>

This gives me my desired user interface!

 
2017-09-25_10-47-07.png

Next I need to have Webdrawer "addMarker" for each record in the results set.  I decided to do this at the very bottom of the results list file, but only if there are records.  And then only for those having a GPS location.

 
if (Model.Results.Count > 0)
{
	<script type="text/javascript">
	@foreach (TrimObject record in Model.Results)
	{
		var gpsloc = record.GetPropertyOrFieldString("RecordGpsLocation");
		if ( !String.IsNullOrWhiteSpace(gpsloc) )
		{
			@Html.Raw("addMarker('" + record.GetPropertyOrFieldString("RecordNumber"+ "','" + gpsloc + "');");
		}
	}
	</script>
}

So now I've got the page calling "addMarker" for each record in the search results.  I need to store each one in memory by pushing it into the array of markers.  I also need to define the addMarkers method called by the initialize function during page load.  In that method I iterate each of the array elements, create a marker on the map, and store that marker reference for later manipulation.

 
<script>
    function addMarker(rnum, ltlg) {
        var marker = { "rnum": rnum, "ltlg": ltlg };
        markers.push(marker);
    }
    function addMarkers(map) {
        for (i = 0; i < markers.length; i++) {
            var marker = markers[i];
 
            if (marker.ltlg.indexOf('POINT('>= 0) {
                var ltlg = marker.ltlg.replace('POINT(''').replace(')''').split(' ');
                var mapmarker = new google.maps.Marker({
                    position: new google.maps.LatLng(ltlg[1], ltlg[0]),
                    map: map,
                    title: marker.rnum
                });
                mapmarkers.push(mapmarker);
            }
        }
    }
</script>

Now if I refresh my results window I can see records plotted on the map.  Success!

 
2017-09-25_11-37-30.png

Whoops.... it's becoming clear that my data import is messed up.  It's including locations beyond my desired regional boundary.  I should only be importing Florida facilities!  That issue will be tabled for now.

Geolocations via Content Manager

Before I go through the trouble of writing more code, I should take a look at what's available.  Hopefully by reviewing each component I can make a list of features missing.  Then I can decide how best to cobble together my final solution.


Accessing Facilities via the Thick Client

When performing a search it is easy to specify that the results include the GPS location in both the view pane and the list pane.  Making the fields visible lets me peruse the data and verify I like the results.  There's no way for me to leverage that data here without a mapping interface.

 
2017-09-24_14-44-29.png

I can use the view pane's GPS Location hyperlink to browse to the location via the map connector.

 
2017-09-24_14-49-37.png

But that's it.  Only potential thing I see here is swapping out the static google map connector for one that embeds an iframe showing results nearby.


Viewing Facilities via Web Client

The out-of-the-box configuration of the Web Client does not support a grid style layout for meta-data in the search results pane.  As you select an individual record in the results, the right-hand view pane exposes a link to the location.  

 
2017-09-24_14-38-28.png

To modify the value you'd either get to the registration form via the update button...

 
2017-09-24_20-00-31.png

Or you click the GPS Location option from the Details drop-down button

 
2017-09-24_15-02-52.png
2017-09-24_15-01-35.png

That's it.  Nothing else to see here! :(


View Facilities via WebDrawer

Webdrawer is a no-thrills interface for the exposure of public records.  Using it implies (at least to me) that you don't care who the user is and you'll just be reading data.  That fits for my scenario, so let's see what's available.

 
2017-09-24_20-06-32.png

Entering "**" into the quick search box and pressing enter should result in all facility boxes being returned (I have nothing else in the dataset).  The search results page shows my boxes, but nothing useful to mapping.  

 
2017-09-24_20-07-44.png

Clicking a facility box yields no mapping fields or data either.  I tried expanding and collapsing all of the regions but no dice.

 
2017-09-24_20-09-32.png

 A quick check of the Webdrawer installation manual and the HPE support site turns up no hits for various relevant keywords. So I visited the ServiceAPI's Property and Field Definition page and see that "RecordGpsLocation" is the property to include in route details.  I add it to both the routes for the search results (WDRecordList) and record details (WDRecordDetails) templates.

 
Note that this is the webdrawer configuration file of a remote server

Note that this is the webdrawer configuration file of a remote server

Modifying the configuration file should result in an automatic reload of the IIS application.  A refresh of my search results page gives me the result shown.

 
2017-09-24_20-27-56.png

A refresh of my record details page does not result in seeing the property.  That's because I added it to the route (which funnels the data in the template), but I did not include it in any of the custom property sets used to group items into the accordion views.  Adding the property to the custom record identification property set should solve the issue.

 
2017-09-24_20-30-00.png

Refreshing the page gave me a quick laugh.  I wish the GPS location was a hyperlink!

 
2017-09-24_20-33-03.png

I'm sure there's a better way to handle this, but I found it easiest to go into the propertyRow partial template and wrap the property value output in a condition.  If the value starts with "https://www.google.com/maps" then replace the output with a valid link.  Otherwise use the normal condition.  Definitely not an ideal solution.

 
2017-09-24_20-43-04.png

Now a reload of the page gives me a usable link.  

 
2017-09-24_20-49-41.png

What's missing?

I have no way, out-of-the-box, to mark search results within the extent of one map.  So re-creating my goal is not possible without more code.  That's ok though.  I've got this! :)