Community Activity Map Explained

By popular demand, below is a technical description and source code of the new Community Activity Map that I posted a couple of weeks ago.

Overview

I started on this back in December 2010 when we were refreshing the community landing page.  Having a list of recent issues, and announcements is all well and good, but there is so much more happening in our worldwide community than the static content I can create, so I wanted a good way to dynamically visualize all of the activity.  Liferay has a built-in activity stream framework, and indeed activities can already be seen in a listing (for example, on my profile page on liferay.com).  However, it is a static display and only relates to my activities.  Wouldn't it be great to see everyone's activities, and the global location at which these activities occur?  Liferay and Google Maps to the rescue!

Portlet or Web Content

Initially, I created a simple MVC-based portlet with a single JSP page.  On the server side, the JSP contained a scriptlet that fetched activities using Liferay's SocialActivity service, and created a giant javascript object in the resulting markup, containing around 20 activities.  The markup is then shipped to the client side, where some more javascript rendered the activities on a google map.  This was all well and good, but unless you refreshed the page, you'd always see the same 20 activities, in a loop, forever.  It also meant that render time was slower because all of the action occured on the server side, and the client didn't see anything until the activities were already fetched and processed.

The other problem was I didn't test it on IE, and when I showed it to Brian Chan, he tried it on IE and predictably it didn't work.  

I abandoned this work due to other priorities, but then about a month ago I found some spare time and resurrected the code.  This time, I decided to make it closer to realtime, and update the page dynamically, so I switched to an AJAX mechanism, which fetched activities using the portlet's serveResource API in an AJAX call (using AlloyUI's built-in AJAX IO library).  I made sure it worked on IE, and submitted the code for review to Peter Shin.  Peter, being the awesome developer he is, thought this would be simpler to maintain if it were purely web content using Liferay's powerful Web Content Management system.  He was able to convert my portlet into pure Velocity-based web content (similar to Ray's example from a few months ago).  This means that its much easier to make updates, and the liferay.com maintainers don't have to be bothered every time I wanted to re-deploy a new version.  Nice, and immense thanks goes to Peter for this!

Web Content Framework

To create apps using Liferay WCM, you create web content structures and templates and articles (this should be familiar to anyone that has used Liferay WCM).  In this case, our structure is simple, and contains a single configurable element called height.  This is then referenced in the template using $height.data.  You could add other configurable parameters (for example, configurable timeouts, or configurable Google Map options, etc).  The web content template has the following framework:

#if ($request.lifecycle == "RENDER_PHASE")
  ## Client-side initial render call will render the result of this code
#elseif ($request.lifecycle == "RESOURCE_PHASE")
  ## Client-side AJAX call will fetch the result of this code
#end
That's it!  The RENDER_PHASE code is akin to a portlet's "View Mode" code (typically a JSP called view.jsp), and the RESOURCE_PHASE is akin to a portlet's serveResource code.

In this case, the RENDER_PHASE contains the javascript for fetching activities via an AlloyUI AJAX call, and then rendering the Google Map, and placing "bubbles" on the map at varying intervals.

The RESOURCE_PHASE results in a JSON object, which is read by code in the RENDER_PHASE, which contains the activities.

When this is all put together, the client side gets the RENDER_PHASE javascript, and executes it, which causes the AJAX call back to Liferay, and returns the result of the RESOURCE_PHASE code.  It's important to NOT make the template cachable, such that every AJAX call results in a new execution of the RESOURCE_PHASE call (otherwise, the same activities would be returned, time and time again).

Fetching and Rendering Activities

The important part of the RESOURCE_PHASE code is here:

#set ($socialActivities = $socialActivityLocalService.getGroupActivities($scopeGroupId, 0, 50))
#foreach ($socialActivity in $socialActivities)
  #set ($socialActivityFeedEntry = $socialActivityInterpreterLocalService.interpret($socialActivity, $themeDisplay))
  ##  do stuff with it
#end
This code retrieves 50 activities from the current group (in our case, the "liferay.com" community on liferay.com), and creates a JSON array, which is returned as a result of this call.
 
Each activity is placed in the array and consists of the following items:
  • body - The rendered content for the activity (rendered via the socialActivityInterpreterLocalService.interpret() call)
  • description - The description of the time of the activity (e.g. "A few seconds ago", "Yesterday", or a date/time)
  • geocoderAddress - The location information for the user who did the activity (more on this below)
  • title - The title of the activity
  • userDisplayURL - The user's profile URL
  • userFullName - The user's name
  • userPortraitURL - The URL to the user's profile picture
The resulting JSON object output by the RESOURCE_PHASE looks like
{
  "jsonArray": [{"body":"....", "geocoderAddress", "France", ...}, {...more activities here...}]
}

 

Geocoder Details

This app uses Google's Geocoder functionality to turn a human-readable address into a Latitude/Longitude, for placing on the map.  The address is taken from your profile, either your full address (if you entered it), or your country (if you entered it), and failing that, defaults to Liferay's headquarters near Los Angeles :)  We do NOT take your full street address.  Don't want anyone visiting you and telling you your wiki edit was wrong :)
 
To fetch the user's address, we make a call to Liferay's AddressService and look for the user's "Primary" address:
#foreach ($address in $user.getAddresses())
  #if ($address.isPrimary())
    #set ($city = $address.getCity())
    #set ($region = $address.getRegion().getName())
    #set ($country = $address.getCountry().getName())
    ## more stuff 
  #end
#end
 
If the user has not entered their address, we then look at a custom User Attribute called country that we have on liferay.com:
#set ($country = $user.getExpandoBridge().getAttribute("country"))
Once the user's location is known, it is stored in the resulting JSON object and shipped to the client during the AJAX call, where we pass the address to Google's geocoder API (or fetch a cached result if the address has already been geocoded).
 

Time Description

A simple way to make a more human-readable timestamp, by looking at how long ago an activity occurred, and generating a nice looking string to represent it.
#set ($now = $dateUtil.newDate())
#set ($millisecondsBetween = $now.getTime() - $socialActivity.getCreateDate())
#if ($millisecondsBetween < 60000)
  #set ($description = $languageUtil.get($locale, "a-few-seconds-ago"))
#elseif ....
  ## more strings for different time differences
#end
 

Making the AJAX call

Using AlloyUI, a simple call is made back to the server, to fetch the results of the RESOURCE_PHASE code, using the below javascript:
function ${portletNamespace}getSocialActivities() {
  AUI().use(
    "aui-base", "aui-io-plugin", "aui-io-request",
    function(A) {
      A.io.request(
        "${request.resource-url}",
        {
          data: {
          },
          dataType: "json",
          on: {
            success: function(event, id, obj) {
              var responseData = this.get("responseData");
              ${portletNamespace}socialActivityCache = responseData.jsonArray || [];
              ${portletNamespace}renderSocialActivity();
            },
          failure: function(event, id, obj) {
          }
        }
      }
    );
  }
);

}
The resulting JSON object is stored, and the renderSocialActivity() function is called to loop through the activities.
 

Rendering the map

You can see the javascript that is used to render the map in the source code below.
var ${portletNamespace}googleMap = new google.maps.Map(
  document.getElementById("${portletNamespace}map"),
  {
    center: new google.maps.LatLng(35, 0),
    mapTypeControl: false,
    mapTypeId: google.maps.MapTypeId.ROADMAP,
    navigationControl: false,
    scaleControl: false,
    streetViewControl: false,
    zoom: 2
  }
The (35,0) is arbitrarily in Northern Algeria.  Don't ask where I came up with that :)  You can turn on additional map controls (e.g. the ability to switch to sattelite view vs. road vew), and configure the default zoom level, with the above options object (you could even make these part of your web content structure, to make it easier to tweak).
 
The javascript code that actually places the bubbles on the map:
function ${portletNamespace}openInfoWindow(position, content, zIndex) {
	var infoWindow = new google.maps.InfoWindow(
		{
			content: content,
			position: position
		}
	);

	infoWindow.setOptions(
		{
			disableAutoPan: false,
			zIndex: zIndex
		}
	);

	infoWindow.open(${portletNamespace}googleMap);

	setTimeout(
		function() {
			infoWindow.close();
		},
		15000
	);
}

Notice the hard-coded values for timeouts. This could be improved by making it part of the web content's structure (like height), so that it could be configured easier. Also notice the zIndex argument - this makes sure that newer bubbles appear on top of older bubbles.

The geocoderAddress returned as part of the JSON object (from the RESOURCE_PHASE) is geocoded using Google's geocoder API:

 

geocoder.geocode(
  {"address": geocoderAddress},
  function(results, status) {
    if (status == google.maps.GeocoderStatus.OK) {
      ${portletNamespace}openInfoWindow(results[0].geometry.location, content, ${portletNamespace}index);
      ${portletNamespace}geocoderAddressCache[geocoderAddress] = results[0].geometry.location;
    }
  }
);
Notice we place the resulting address in a cache object, so we don't have to geocode the same address again (Google places a limit on the number of geocoding calls a given IP address can make in a given day).
 

Timeouts

This app is not truly realtime.  For a truly realtime result (which would be rather boring, since only one or two activities occur every minute), one would need to install some kind of ModelListener hook into Liferay, listening for new activities, and maintain a long-running comet-style open connection (similar to how liferay.com's chat functionality works) to feed new activities to the client side.  Instead, there are various timeouts that occur in the javascript which place the most recent block of activities on the map at random intervals.  Each activity's Time To Live is hard-coded at 15 seconds (again, this could be configured via a web content structure element), and the period between events is random between 10 and 20 seconds using this call:
setTimeout("${portletNamespace}renderSocialActivity()", 10000 + (Math.floor(Math.random() * 10000)));
After all of the activities in a given block are rendered, a new set of activities is fetched using the same AJAX call as before, and the loop begins all over again.
 

Random Notes and Improvements

There is a lot of noisy #set directives in the Velocity code in the RESOURCE_PHASE, because of the nuances of Liferay's WCM (in particular, because you don't have access to a "live" ThemeDisplay object, we have to create one and populate it with necessary content from the "sparse" name/value theme-display property available from the request object, which is available to WCM templates).  Don't let it distract you!
 
I am hoping some of you may be interested in making improvements, such as:
  • Making more things configurable, like the timeouts, map options, etc
  • Making it truly real-time using a ModelListener Hook as described above
  • Fetching more kinds of activities (for example, right now, the map won't show blog entries, since these occur in the user's "group", not the liferay.com group)
  • Accessing activities from outside of liferay.com!  Perhaps integrating with your Facebook profile, the Liferay twitter stream, or some other activity feed
  • Others?

 

The full Source Code

If you want to use this for your own purposes, below is the source code.  Here are the steps to use this:
  1. Create a web content structure, and cut and paste the below structure code into it.
  2. Create a web content template, associate it with the above newly-created structure, and cut and paste the below Velocity code into your template.
  3. Create a new web content article, selecting the above structure and template.
  4. Add a "Web Content Display" portlet to a page, and configure it to show the newly created article created in step 3.
#set ($portletNamespace = $request.portlet-namespace)
#set ($scopeGroupId = $getterUtil.getLong($request.theme-display.scope-group-id))
#set ($timeZone = $timeZoneUtil.getTimeZone($request.theme-display.time-zone))
#set ($userId = $getterUtil.getLong($request.theme-display.user-id))

#set ($height = $getterUtil.getString($height.data, "300"))

#if ($request.lifecycle == "RENDER_PHASE")
  <link href="//code.google.com/apis/maps/documentation/javascript/examples/standard.css" rel="stylesheet" type="text/css" />

  <script type="text/javascript" src="//maps.google.com/maps/api/js?sensor=false"></script>

  <div id="${portletNamespace}map" style="height: ${height}px; margin-bottom: 1.5em; width: 100%;"><!-- --></div>

  <script type="text/javascript">
    var ${portletNamespace}geocoderAddressCache = new Object();

    var ${portletNamespace}googleMap = new google.maps.Map(
      document.getElementById("${portletNamespace}map"),
      {
        center: new google.maps.LatLng(35, 0),
        mapTypeControl: false,
        mapTypeId: google.maps.MapTypeId.ROADMAP,
        navigationControl: false,
        scaleControl: false,
        streetViewControl: false,
        zoom: 2
      }
    );

    google.maps.event.addDomListener(window, "load", ${portletNamespace}getSocialActivities);

    var ${portletNamespace}index = 0;
    var ${portletNamespace}socialActivityCache = [];

    function ${portletNamespace}getSocialActivities() {
      AUI().use(
        "aui-base", "aui-io-plugin", "aui-io-request",
        function(A) {
          A.io.request(
            "${request.resource-url}",
            {
              data: {
              },
              dataType: "json",
              on: {
                success: function(event, id, obj) {
                  var responseData = this.get("responseData");

                  ${portletNamespace}socialActivityCache = responseData.jsonArray || [];

                  ${portletNamespace}renderSocialActivity();
                },
                failure: function(event, id, obj) {
                }
              }
            }
          );
        }
      );
    }

    function ${portletNamespace}openInfoWindow(position, content, zIndex) {
      var infoWindow = new google.maps.InfoWindow(
        {
          content: content,
          position: position
        }
      );

      infoWindow.setOptions(
        {
          disableAutoPan: false,
          zIndex: zIndex
        }
      );

      infoWindow.open(${portletNamespace}googleMap);

      setTimeout(
        function() {
          infoWindow.close();
        },
        15000
      );
    }

    function ${portletNamespace}renderSocialActivity() {
      if (${portletNamespace}socialActivityCache.length <= 0) {
        ${portletNamespace}openInfoWindow(new google.maps.LatLng(35, 0), Liferay.Language.get("there-are-no-recent-activities"), 1);

        return;
      }

      if (${portletNamespace}index >= ${portletNamespace}socialActivityCache.length) {
        ${portletNamespace}index = 0;

        setTimeout("${portletNamespace}getSocialActivities()", 1000);

        return;
      }

      var content =  '<div>' +
              '  <a href="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userDisplayURL + '">' +
              '    <img alt="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userFullName + '" style="float: left;" height="44" hspace="4" vspace="4" src="' + ${portletNamespace}socialActivityCache[${portletNamespace}index].userPortraitURL + '" />' +
              '  </a>' +
              '  <div>' +
                  ${portletNamespace}socialActivityCache[${portletNamespace}index].description +
              '  </div>' +
              '  <div>' +
                  ${portletNamespace}socialActivityCache[${portletNamespace}index].title +
              '  </div>' +
              '  <div>' +
                  ${portletNamespace}socialActivityCache[${portletNamespace}index].body +
              '  </div>' +
              '</div>';

      var geocoderAddress = ${portletNamespace}geocoderAddressCache[${portletNamespace}socialActivityCache[${portletNamespace}index].geocoderAddress];

      if (geocoderAddress) {
        ${portletNamespace}openInfoWindow(geocoderAddress, content, ${portletNamespace}index);
      }
      else {
        var geocoder = new google.maps.Geocoder();

        geocoderAddress = ${portletNamespace}socialActivityCache[${portletNamespace}index].geocoderAddress;

        geocoder.geocode(
          {"address": geocoderAddress},
          function(results, status) {
            if (status == google.maps.GeocoderStatus.OK) {
              ${portletNamespace}openInfoWindow(results[0].geometry.location, content, ${portletNamespace}index);

              ${portletNamespace}geocoderAddressCache[geocoderAddress] = results[0].geometry.location;
            }
          }
        );
      }

      ${portletNamespace}index = ${portletNamespace}index + 1;

      setTimeout("${portletNamespace}renderSocialActivity()", 10000 + (Math.floor(Math.random() * 10000)));
    }
  </script>
#elseif ($request.lifecycle == "RESOURCE_PHASE")
  #set ($logFactory = $portal.getClass().forName("com.liferay.portal.kernel.log.LogFactoryUtil"))
  #set ($log = $logFactory.getLog("com.liferay.portal.util.PortalImpl"))
  #set ($portalBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortalBeanLocatorUtil"))
  #set ($portletBeanLocator = $portal.getClass().forName("com.liferay.portal.kernel.bean.PortletBeanLocatorUtil"))

  #set ($fastDateFormatFactoryUtil = $portal.getClass().forName("com.liferay.portal.kernel.util.FastDateFormatFactoryUtil"))
  #set ($jsonFactory = $portalBeanLocator.locate("com.liferay.portal.kernel.json.JSONFactoryUtil"))
  #set ($permissionThreadLocal = $portal.getClass().forName("com.liferay.portal.security.permission.PermissionThreadLocal"))
  #set ($socialActivityInterpreterLocalService = $portalBeanLocator.locate("com.liferay.portlet.social.service.SocialActivityInterpreterLocalService.velocity"))
  #set ($socialActivityLocalService = $portalBeanLocator.locate("com.liferay.portlet.social.service.SocialActivityLocalService.velocity"))
  #set ($userLocalService = $portalBeanLocator.locate("com.liferay.portal.service.UserLocalService.velocity"))

  #set ($dateFormatDateTime = $fastDateFormatFactoryUtil.getDateTime(1, 3, $locale, $timeZone))
  #set ($portalURL = $httpUtil.getProtocol($request.attributes.CURRENT_URL) + "://" + $getterUtil.getString($request.theme-display.portal-url))

  #set ($themeDisplay = $portal.getClass().forName("com.liferay.portal.theme.ThemeDisplay").newInstance())

  #set ($V = $themeDisplay.setLocale($locale))
  #set ($V = $themeDisplay.setPathImage($getterUtil.getString($request.theme-display.path-image)))
  #set ($V = $themeDisplay.setPathMain($getterUtil.getString($request.theme-display.path-main)))
  #set ($V = $themeDisplay.setPermissionChecker($permissionThreadLocal.getPermissionChecker()))
  #set ($V = $themeDisplay.setPortalURL($portalURL))
  #set ($V = $themeDisplay.setScopeGroupId($scopeGroupId))
  #set ($V = $themeDisplay.setTimeZone($request.theme-display.time-zone))
  #set ($V = $themeDisplay.setUser($userLocalService.getUserById($userId)))

  #set ($socialActivities = $socialActivityLocalService.getGroupActivities($scopeGroupId, 0, 50))

  #set ($jsonArray = $jsonFactory.createJSONArray())

  #foreach ($socialActivity in $socialActivities)
    #set ($socialActivityFeedEntry = $socialActivityInterpreterLocalService.interpret($socialActivity, $themeDisplay))

    #if ($validator.isNotNull($socialActivityFeedEntry))
      #set ($user = $userLocalService.getUserById($socialActivity.getUserId()))

      #set ($geocoderAddress = "")

      #foreach ($address in $user.getAddresses())
        #if ($address.isPrimary())
          #set ($city = $address.getCity())
          #set ($region = $address.getRegion().getName())
          #set ($country = $address.getCountry().getName())

          #set ($s = "")

          #if ($validator.isNotNull($city))
            #set ($s = $s + $city + ",")
          #end

          #if ($validator.isNotNull($region))
            #set ($s = $s + $region + ",")
          #end

          #if ($validator.isNotNull($country))
            #set ($s = $s + $country)
          #end

          #if ($validator.isNotNull($s))
            #set ($geocoderAddress = $s)
          #end
        #end
      #end

      #if ($validator.isNull($geocoderAddress))
        #set ($country = $user.getExpandoBridge().getAttribute("country"))

        #if ($validator.isNotNull($country))
          #set ($geocoderAddress = $languageUtil.get($locale, $stringUtil.merge($country)))
        #end
      #end

      #if ($validator.isNull($geocoderAddress))
        #set ($geocoderAddress = "Walnut, CA, United States of America")
      #end

      #set ($now = $dateUtil.newDate())

      #set ($millisecondsBetween = $now.getTime() - $socialActivity.getCreateDate())
      #set ($description = $dateFormatDateTime.format($socialActivity.getCreateDate()))

      #if ($millisecondsBetween < 60000)
        #set ($description = $languageUtil.get($locale, "a-few-seconds-ago"))

        #if ($validator.equals($description, "a-few-seconds-ago"))
          #set ($description = "A few seconds ago.")
        #end
      #elseif ($millisecondsBetween < 3600000)
        #set ($minutes = $millisecondsBetween / 60000)

        #set ($description = $languageUtil.format($locale, "about-x-minutes-ago", $stringUtil.merge([$minutes]), false))

        #if ($validator.equals($description, "about-x-minutes-ago"))
          #set ($description = "About " + $minutes + " minute(s) ago.")
        #end
      #elseif ($millisecondsBetween < 86400000)
        #set ($hours = $millisecondsBetween / 3600000)

        #set ($description = $languageUtil.format($locale, "about-x-hours-ago", $stringUtil.merge([$hours]), false))

        #if ($validator.equals($description, "about-x-hours-ago"))
          #set ($description = "About " + $hours + " hour(s) ago.")
        #end
      #elseif ($millisecondsBetween < 604800000)
        #set ($days = $dateUtil.getDaysBetween($dateUtil.newDate($socialActivity.getCreateDate()), $now, $timeZone))

        #set ($description = $languageUtil.format($locale, "about-x-days-ago", $stringUtil.merge([$days]), false))

        #if ($validator.equals($description, "about-x-days-ago"))
          #set ($description = "About " + $days + " day(s) ago.")
        #end
      #end

      #set ($jsonObject = $jsonFactory.createJSONObject())

      #set ($V = $jsonObject.put("body", $socialActivityFeedEntry.getBody()))
      #set ($V = $jsonObject.put("description", $description))
      #set ($V = $jsonObject.put("geocoderAddress", $geocoderAddress))
      #set ($V = $jsonObject.put("title", $socialActivityFeedEntry.getTitle()))
      #set ($V = $jsonObject.put("userDisplayURL", $user.getDisplayURL($themeDisplay)))
      #set ($V = $jsonObject.put("userFullName", $htmlUtil.escape($user.getFullName())))
      #set ($V = $jsonObject.put("userPortraitURL", $user.getPortraitURL($themeDisplay)))

      #set ($V = $jsonArray.put($jsonObject))
    #end
  #end

  {
    "jsonArray": $jsonArray
  }
#end

The WCM structure is pretty darn simple:

<root>
  <dynamic-element name='height' type='text' index-type='' repeatable='false'/>
</root>

That's all folks!

Blogues
Can any one guide to me how we can done clustering in liferay using jboss with full of configurations ?
By following the installation instructions in the last chapter, all i get for users data is indeed what comes as a default from the US Headquarters address.
User has set-up Address (street, city, zip and country), but the data show always that changes appeared in US.

Any further hints?
PS. Tries also with the custom attribute named "country"
@Natasa @Dieter What version of Liferay are you using? The above source code is written for 6.0 SP2 (this is the version we are currently running on liferay.com). The "country" custom attribute on liferay.com is of type "group of text values", which means the value returned by $user.getExpandoBridge().getAttribute("country") returns an array of strings, which is why you see this in the source code: $stringUtil.merge($country) (it turns the array into a single string).

If you give me the version of liferay you are using I'll test it on that and see if any changes are required.
Hi James,
thanks for writing this back. This problem was appearing with 6.0.6 CE Liferay version.
I think it somehow worked only with the custom attribute named "country" of type text.
Somehow i was expecting it would also work from the Address provided as a primary address of the user (or if this would be empty, from the user#s organization address).
Thanks, once again
Hi James, thank you for your answer. I´m also using Liferay CE 6.0.6. I tried it with the custom attribute "country" of type text and it shows the right location now. What can I do that ist shows the location from the User Address? I have got still the problem that the User Portrait is´t displayed. Have you got any idee.
Thank you very much. Greetings from Austria.
One more issue. When I´m logged in it shows the location from the custom field "country", but when I´m logged out it shows the L.A. headquater. Why it makes a difference if I´m logged in or out?
Thanks, once again
@Dieter - it shouldn't matter if you're logged in or not. I'm working on making this work in Liferay 6.0.6 now. Stay tuned.
Ok guys I figured it out - check out http://www.liferay.com/community/forums/-/message_boards/message/11789742

@Dieter make sure you set the permissions on the custom attribute so that the guest user can "view" the attribute (see the message board post above for details)
Hi James.
I'm trying to use your template in liferay 6.1b4, without too much success.
I've tracked the problem down to the following call, which doesn't return anything.
#set ($socialActivityFeedEntry = $socialActivityInterpreterLocalService.interpret($socialActivity, $themeDisplay))

Consequently, the check on $socialActivityFeedEntry fails and nothing ends in jsonArray.

Am I missing something?
Thanks in advance!
@Matteo - I tried this on 6.1 - and found a few issues, the biggest of which is a bug in Liferay 6.1 : http://issues.liferay.com/browse/LPS-24172 once this is fixed, things should work right. I'll let you know.
@Matteo - with Ray's help we figured out the problem. See http://www.liferay.com/community/forums/-/message_boards/view_message/11789992#_19_message_11806835
@james could you try to use protocol-relative links to load the code from google? E.g. <link href="//code.google.com/apis/.....">
This would work with https to google if the liferay page is accessed through https, http otherwise.
This works well e.g. in google's weather api, I assume it would do here as well.
Great Job Mr. James Falkner.....Nicely Explained ,
Thanks for sharing........
Is there any way to scope this thing portal-wide (showing everyone's activities)?

Thanks