Nuxeo/Blogs

Product & Development / All about the Nuxeo Platform, from strategy to feature highlights to dev tricks

[Monday Dev Heaven] Playing with OpenLayers to add GeoLocation to Nuxeo

without comments

Last week I started playing with OpenLayers. It’s an open source JavaScript library that makes it easy to add a dynamic map to a web page. And indeed, they have a wonderful API — it’s easy to use, with good documentation and lots of examples. I’ve really enjoyed this coding session :D

So what I wanted to do with it was quite simple. I wanted to have the possibility to add geographical coordinates to a document and I wanted to display all of them on a map. Once they’re on the map, it would be nice to be able to click on the pointer and display the title and link to the document. Here’s what it looks like in the end:

Update coordinates example

Map pointer exemple

Saving map coordinates to any document

Since I don’t necessarily need every document to be tagged, I’m going to use a mixin. Its associated schema will have two String fields latitude and longitude. This is all I need to position something on a map. I’ll also add the associated DocumentAdapter. Let’s get to it:

The schema and facet contribution:

  <extension target="org.nuxeo.ecm.core.schema.TypeService" point="schema">
    <schema name="geolocalization" src="schemas/geolocalization.xsd" prefix="thumb" />
  </extension>

  <extension target="org.nuxeo.ecm.core.schema.TypeService" point="doctype">
    <facet name="GeoLocalization">
      <schema name="geolocalization" />
    </facet>
  </extension>

And the adapter:

   <extension target="org.nuxeo.ecm.core.api.DocumentAdapterService" point="adapters">
       <adapter facet="GeoLocalization"
           class="org.nuxeo.geolocalization.GeoLocalization"
           factory="org.nuxeo.geolocalization.GeoLocalizationFactory"/>
   </extension>

It will return the GeoLocalization class instance only when it has the appropriate facet. This adapter has only two public methods: getLatitude and getLongitude.

I have the data structure so now I need somewhere to put my map widget. Let’s add a new tab without any filter so that every document will have it. When the document doesn’t have the GeoLocalization facet, I’ll put a button to add it. Then we can display the map, the selected coordinates and a button to save them as document properties.

The tab is made using a contribution to the actions extension points:

  <extension target="org.nuxeo.ecm.platform.actions.ActionService"
    point="actions">

    <action id="org.nuxeo.geolocalization.geolocalizationManagerBean" link="/incl/tabs/org.nuxeo.geolocalization.geolocalizationManagerBean-tab.xhtml"
      label="label.org.nuxeo.geolocalization.geolocalizationManagerBean"
      icon="/icons/org.nuxeo.geolocalization.geolocalizationManagerBean-tab.gif" order="200">
      <category>VIEW_ACTION_LIST</category>
    </action>
  </extension>

This code was generated by the Seam Controller Bean wizard from Nuxeo IDE. It also generates the Seam bean and an XHTML template. Let’s start with the Seam bean. I need two methods, one to add the facet and one to save the document properties.

package org.nuxeo.geolocalization;

import java.io.Serializable;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jboss.seam.ScopeType;
import org.jboss.seam.annotations.In;
import org.jboss.seam.annotations.Name;
import org.jboss.seam.annotations.Scope;
import org.nuxeo.ecm.core.api.ClientException;
import org.nuxeo.ecm.core.api.CoreSession;
import org.nuxeo.ecm.core.api.DocumentModel;
import org.nuxeo.ecm.platform.ui.web.invalidations.AutomaticDocumentBasedInvalidation;

@Name("geoLocalizationManager")
@Scope(ScopeType.CONVERSATION)
@AutomaticDocumentBasedInvalidation
public class GeoLocalizationManagerBean implements Serializable {

	private static final long serialVersionUID = 1L;

	private static final Log log = LogFactory
			.getLog(GeoLocalizationManagerBean.class);

	@In(create = true, required = false)
	protected transient CoreSession documentManager;

	public String addGeoLocalization(DocumentModel document) {
		document.addFacet(GeoLocalizationConstant.LOCALIZATION_FACET_NAME);
		return null;
	}

	public String updateGeoLocalization(DocumentModel document) throws ClientException {
		documentManager.saveDocument(document);
		return null;
	}

}

Returning null each time will reload the current page. So about that page — defined in the previous actions XP contribution — it starts by testing if the current document h

<div xmlns:h="http://java.sun.com/jsf/html"
  xmlns:f="http://java.sun.com/jsf/core"
  xmlns:ui="http://java.sun.com/jsf/facelets"
  xmlns:c="http://java.sun.com/jstl/core"
  xmlns:fn="http://java.sun.com/jsp/jstl/functions"
  xmlns:nxd="http://nuxeo.org/nxweb/document"
  xmlns:a4j="https://ajax4jsf.dev.java.net/ajax"
  xmlns:nxdir="http://nuxeo.org/nxdirectory"
  xmlns:nxu="http://nuxeo.org/nxweb/util"
  xmlns:nxh="http://nuxeo.org/nxweb/html">

<c:if test="#{currentDocument.hasFacet('GeoLocalization')}">
<c:if test="false">
  The document has the GeoLocalization facet. We can display a map using OpenLayers and a form to update coordinate.
  inputHidden tags values are set using JavaScript each time user clicks on the map.
</c:if>
  <h:form id="geoLocalizationForm">
  <table class="dataInput">
    <tr>
      <td><h:outputText class="labelColumn" value="#{messages['label.geolocalization.latitude']}" /></td>
      <td><h:inputText class="fieldColumn,dataInputText" readonly="true" id="outputLatitude" value="#{currentDocument.geolocalization.latitude}" />
      <h:inputHidden id="inputLatitude" value="#{currentDocument.geolocalization.latitude}" /></td>
      <td><h:outputText class="labelColumn" value="#{messages['label.geolocalization.longitude']}" /></td>
      <td><h:inputText class="fieldColumn,dataInputText" readonly="true" id="outputLongitude" value="#{currentDocument.geolocalization.longitude}" />
      <h:inputHidden id="inputLongitude" value="#{currentDocument.geolocalization.longitude}" /></td>
  
      <td><a4j:commandButton value="#{messages['command.geolocalization.update']}" action="#{geoLocalizationManager.updateGeoLocalization(currentDocument)}" styleClass="button"/></td>
      <td>
        <a4j:status>
          <f:facet name="start">
            <h:graphicImage value="/img/standart_waiter.gif" />
          </f:facet>
        </a4j:status>
      </td>
    </tr>
  </table>
  <div style="width:100%; height:400px" class="smallmap" id="map"></div>

  <script type="text/javascript">
        var map, layer, singleMarker;
        var lat = '#{currentDocument.geolocalization.latitude}';
        var lon = '#{currentDocument.geolocalization.longitude}';
        var title = '#{currentDocument.title}';
        map = new OpenLayers.Map('map');
        layer = new OpenLayers.Layer.WMS( "OpenLayers WMS", 
            "http://vmap0.tiles.osgeo.org/wms/vmap0", {layers: 'basic'} );
        map.addControl(new OpenLayers.Control.MousePosition());
            
        map.addLayer(layer);
        map.setCenter(new OpenLayers.LonLat(0, 0), 0);

        var markers = new OpenLayers.Layer.Markers( "Markers" );
        map.addLayer(markers);

        var size = new OpenLayers.Size(21,25);
        var offset = new OpenLayers.Pixel(-(size.w/2), -size.h);
        var icon = new OpenLayers.Icon('#{contextPath}/img/marker.png',size,offset);
        // document already have a latitude, display the pointer.
        if (lat) {
            singleMarker = new OpenLayers.Marker(new OpenLayers.LonLat(lon,lat),icon);
            markers.addMarker(singleMarker);
        }
        map.events.register("click", map, function(e) {
         if (singleMarker) {
        	 markers.removeMarker(singleMarker);
         }
         var position = map.getLonLatFromPixel(e.xy);
         singleMarker = new OpenLayers.Marker(position,icon);
         markers.addMarker(singleMarker);
         // update the hiddenInput values 
         OpenLayers.Util.getElement("geoLocalizationForm:inputLatitude").value = position.lat;
         OpenLayers.Util.getElement("geoLocalizationForm:outputLatitude").value = position.lat;
         OpenLayers.Util.getElement("geoLocalizationForm:inputLongitude").value = position.lon;
         OpenLayers.Util.getElement("geoLocalizationForm:outputLongitude").value = position.lon;

        });
        map.addControl(new OpenLayers.Control.LayerSwitcher());
        map.zoomToMaxExtent();

    </script>
  </h:form>
</c:if>

<c:if test="#{not currentDocument.hasFacet('GeoLocalization')}">
  <h:form>
    <dl>
        <dd><nxh:commandButton value="#{messages['command.geolocalization.add']}" 
          action="#{geoLocalizationManager.addGeoLocalization(currentDocument)}" styleClass="button"/></dd>            
    </dl>
  </h:form>
</c:if>

</div>

In this template, I’m using the OpenLayers JavaScript library. It’s a nice toolkit for drawing a map on a web page. It supports multiple layers, which means I can display Google Maps or OpenStreetMap layers. For now, I’ll use the default one.

So I need to deploy it on the server. The following contribution will declare OpenLayers.js as a <a title="Static resource documentation" href="http://doc.nuxeo.com/x/iYGe#Theme-Themestaticresources">static resource</a> and will add it to the default page:
  <extension target="org.nuxeo.theme.styling.service" point="resources">
    <resource name="openlayer.js">
      <path>scripts/openlayers/OpenLayers.js</path>
    </resource>
  </extension>
  <extension target="org.nuxeo.theme.styling.service" point="pages">
    <themePage name="galaxy/default">
      <resources append="true">
        <resource>openlayer.js</resource>
      </resources>
    </themePage>
  </extension>

There is also a bunch of static resources, like images, that I need to deploy on the server. I’ve copied them into the nuxeo.war folder. They will be copied each time nuxeo.war is generated while starting the server because of what I’ve put into my deployment-fragment.xml:

  <install>
    <unzip from="${bundle.fileName}" to="/" prefix="web">
      <include>web/nuxeo.war/**</include>
    </unzip>
    
    <delete path="${bundle.fileName}.tmp"/>
    <unzip from="${bundle.fileName}" to="${bundle.fileName}.tmp" prefix="OSGI-INF/l10n">
      <include>OSGI-INF/l10n/*-messages.properties</include>
    </unzip>
    <append from="${bundle.fileName}.tmp" pattern="*-messages.properties" to="nuxeo.war/WEB-INF/classes/messages.properties" addNewLine="true"/>
    <delete path="${bundle.fileName}.tmp"/>
  </install>

If you are familiar with Ant, you will recognize Ant tags. It’s quite simple to understand. It unzips the war content inside the server’s war when it starts. That’s the way Nuxeo makes its modular war. You’ll also notice that we appended some labels to the messages.properties file.

Displaying every localized document in a map

Now I’m able to add coordinates to any document. It’s time to display every localized document on a map. I’ll make a new page displaying it that is accessible from the user menu. To gather all the pointers, I need to do a query on all documents with the GeoLocalization facet. I will send the result as a JSON object containing the link, title, latitude and longitude. I will use a4j jsfunction to call this once the page is loaded. This is a simple way to call a SEAM bean method from JavaScript.

Here’s my Java method to retrieve the data and send it as JSON:

	public String loadAllMarker() throws ClientException, JSONException {
		JSONArray array = new JSONArray();
		DocumentModelList documents = documentManager
				.query("Select * FROM Document WHERE ecm:mixinType = 'GeoLocalization'");
		for (DocumentModel documentModel : documents) {
			GeoLocalization geoLocalizedDoc = documentModel
					.getAdapter(GeoLocalization.class);
			JSONObject marker = new JSONObject();
			String lon = geoLocalizedDoc.getLongitude();
			String lat = geoLocalizedDoc.getLatitude();
			if (lon != null && lat != null) {
				marker.put("lon", lon);
				marker.put("lat", lat);
				marker.put("title", documentModel.getTitle());
				marker.put("link",
						DocumentModelFunctions.documentUrl(documentModel));
				array.put(marker);
			}
		}
		setAllMarker(array);
		return null;
	}

And I’m going to bind it to a JavaScript function using the following a4j tag:

  <a4j:jsFunction name="addMarkers" action="#{geoLocalizationManager.loadAllMarker}"
   data="#{geoLocalizationManager.allMarker}" oncomplete="loadWorkerCallback(data)" immediate="true"></a4j:jsFunction>

When I call the addMarkers() function in JavaScript, it will execute the method from the action attribute geoLocalizationManager.loadAllMarker. I can add a JS callback method using the oncomplete attribute. Its data argument is defined by the data attribute (geoLocalizationManager.allMarker). So here’s what it looks like in the end:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
   "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<nxthemes:composition xmlns="http://www.w3.org/1999/xhtml"
  xmlns:ui="http://java.sun.com/jsf/facelets"
  xmlns:f="http://java.sun.com/jsf/core"
  xmlns:h="http://java.sun.com/jsf/html"
  xmlns:c="http://java.sun.com/jstl/core"
  xmlns:nxthemes="http://nuxeo.org/nxthemes"
  xmlns:a4j="https://ajax4jsf.dev.java.net/ajax"
  xmlns:nxd="http://nuxeo.org/nxweb/document"
  xmlns:nxl="http://nuxeo.org/nxforms/layout"
  xmlns:nxu="http://nuxeo.org/nxweb/util">

  <ui:define name="page title">
  <h:outputText value="#{nuxeoApplicationName} - #{messages['command.map']}"/>
  </ui:define>

  <ui:define name="bookmark">
    <link rel="bookmark" href="#{navigationContext.currentDocumentFullUrl}"/>
  </ui:define>

  <ui:define name="body">
  <h:form id="geoLocalizationForm">

  <a4j:jsFunction name="addMarkers" action="#{geoLocalizationManager.loadAllMarker}"
   data="#{geoLocalizationManager.allMarker}" oncomplete="loadWorkerCallback(data)"  immediate="true"></a4j:jsFunction>
   <a4j:status>
      <f:facet name="start">
        <h:graphicImage value="/img/standart_waiter.gif" />
      </f:facet>
   </a4j:status>
<div style="width:100%; height:400px" class="smallmap" id="map"></div>

  <script type="text/javascript">
        var map, layer, currentPopup;

        AutoSizeAnchoredMaxSize = OpenLayers.Class(OpenLayers.Popup.Anchored, {
            'autoSize': true, 
            'maxSize': new OpenLayers.Size(100,100)
        });

        map = new OpenLayers.Map('map');
        layer = new OpenLayers.Layer.WMS( "OpenLayers WMS", 
            "http://vmap0.tiles.osgeo.org/wms/vmap0", {layers: 'basic'} );
        map.addControl(new OpenLayers.Control.MousePosition());
            
        map.addLayer(layer);
        map.setCenter(new OpenLayers.LonLat(0, 0), 0);

        var markers = new OpenLayers.Layer.Markers( "Markers" );
        map.addLayer(markers);

        map.addControl(new OpenLayers.Control.LayerSwitcher());
        map.zoomToMaxExtent();

      
        function loadWorkerCallback(data) {
                var jsonData = JSON.parse(data);
        	for (var i =0;i&lt;jsonData.length;i++) {
        		ll = new OpenLayers.LonLat(jsonData[i].lon,jsonData[i].lat);
                popupClass = AutoSizeAnchoredMaxSize;
                popupContentHTML = '<a href="'+jsonData[i].link+'">'+jsonData[i].title+'</a>';
                addMarker(ll, popupClass, popupContentHTML, true, true);
        	}
        };
  
        function addMarker(ll, popupClass, popupContentHTML, closeBox, overflow) {

            var feature = new OpenLayers.Feature(markers, ll); 
            feature.closeBox = closeBox;
            feature.popupClass = popupClass;
            feature.data.popupContentHTML = popupContentHTML;
            feature.data.overflow = (overflow) ? 'auto' : 'hidden';
                    
            var marker = feature.createMarker();

            var markerClick = function (evt) {
                if (this.popup == null) {
                    this.popup = this.createPopup(this.closeBox);
                    map.addPopup(this.popup);
                    this.popup.show();
                } else {
                    this.popup.toggle();
                }
                currentPopup = this.popup;
                OpenLayers.Event.stop(evt);
            };
            marker.events.register('mousedown', feature, markerClick);

            markers.addMarker(marker);
        };
        jQuery(document).ready(addMarkers);
    </script>
  </h:form>
</ui:define>

</nxthemes:composition>

The addMarkers JS function is called when the DOM document is fully loaded using the JQuery ready function: jQuery(document).ready(addMarkers);.

To add a link to this page, as usual I can use the action extension point:

  <extension target="org.nuxeo.ecm.platform.actions.ActionService"
    point="actions">
    <action id="document_map" link="#{geoLocalizationManager.goToMap()}"
      label="command.map" order="10">
      <category>USER_MENU_ACTIONS</category>
    </action>
  </extension>

The link here points to a Seam method that returns “geo_localization_map”. It navigates to the map.xhtml file because I’ve added the following to my deployment-fragment:

  <extension target="pages#PAGES">
    <page view-id="/map.xhtml" >
      breadcrumb=command.map
    </page>
  </extension>

  <extension target="faces-config#NAVIGATION">
    <!-- Map of geolocalized documents -->
    <navigation-case>
      <from-outcome>geo_localization_map</from-outcome>
      <to-view-id>/map.xhtml</to-view-id>
      <redirect />
    </navigation-case>
  </extension>

This is again an example of how we have a modular WAR in Nuxeo. When the server starts, it adds my navigation case and my page in the appropriate files faces-config.xml and pages.xml. Under the hood, it’s a very simple templating system.

And that’s about it :) Now I have access to a map displaying a pointer for every geolocalized document. I won’t go into details for OpenLayers API, because I won’t be as effective as their documentation or examples. Check them out if you’re curious.

As usual, you can find the code on GitHub. If you’re inpspired, go ahead and fork it :) There are many possible improvements. The first one that comes to my mind is to enhance the popup displaying the document’s title and link. There are many other metadata items that could be displayed (like a thumbnail…).

See ya’ on Friday for a new question from answers.nuxeo.com!

June 19th, 2012 at 4:25 pm

About Laurent Doguin

Laurent works as developer and community liaison at Nuxeo, a software company providing a full Enterprise Content Management Platform, open source, for any kind of content-driven application.