Thursday, October 10, 2013

OpenLayers & AngularJS - add features, choose their appearance and behaviour with clustering

Being a beginner in both OpenLayers and AngularJS it took me a long while to do this simple thing: add stuff on a map and make it show as I wanted. There were multiple gotchas and I intend to chronicle each and every one of those bastards.
First, while creating a map and doing all kinds of stuff with it using OpenLayers is a breeze, doing it "right" with AngularJS is not as simple. I thought I would not reinvent the wheel and looked for some integration of the two technologies and I found AzimuthJS. In order to add a map with Azimuth all you have to do is:
<div ol-map controls="zoom,navigation,layerSwitcher,attribution,mousePosition" control-opts="{navigation:{handleRightClicks:true}}">
    <az-layer name="Street" lyr-type="tiles"></az-layer>
    <az-layer name="Airports" lyr-type="geojson" lyr-url="examples/data/airports.json" projection="EPSG:4326"></az-layer>
You may notice that it has a simple syntax, it offers the possibility of multiple layers and one of them is even loading features dynamically from a URL. Perfect so far.
First problem: the API that I am using is not in the GeoJSON format that Azimuth know how to handle and I cannot or will not change the API. I've tried a lot of weird crap, including adding a callback on the loadend layer event for a GeoJson layer in order to reparse the data and configure what I wanted. It all worked, but it was incredibly ugly. I've managed to add the entire logic in a Javascript file and do it all in that event. It wasn't any different from doing it from scratch in Javascript without any Angular syntax, though. So what I did was to create my own OpenLayers.Format. It wasn't so complicated, basically I inherited from OpenLayers.Format.JSON and added my own read logic. Here is the result:
OpenLayers.Format.RSI = OpenLayers.Class(OpenLayers.Format.JSON, {

    read: function(json, type, filter) {
        type = (type) ? type : "FeatureCollection";
        var results = null;
        var obj = null;
        if (typeof json == "string") {
            obj =,
                                                              [json, filter]);
        } else { 
            obj = json;
        if(!obj) {
            OpenLayers.Console.error("Bad JSON: " + json);

        var features=[];
        for (var i=0; i<obj.length; i++) {
            var item=obj[i];
            var point=new OpenLayers.Geometry.Point(item.Lon,item.Lat).transform('EPSG:4326', 'EPSG:3857');
            if (!isNaN(point.x)&&!isNaN(point.y)) {
                var feature=new OpenLayers.Feature.Vector(point,item);
        return features;

    CLASS_NAME: "OpenLayers.Format.RSI" 

All I had to do is load this in the page. But now the problem was that Azimuth only knows some types of layers based on a switch block. I've not refactored the code to be plug and play, instead I shamelessly changed it to try to use the GeoJson code with the format I provide as the lyr-type, if it exists in the OpenLayers.Format object. That settled that. By running the code so far I see the streets layer and on top of it a lot of yellow circles for each of my items.
Next problem: too many items. The map was very slow because I was adding over 30000 items on the map. I was in need of clustering. I wasted almost an entire day trying to figure out what it wouldn't work until I realised that it was an ordering issue. Duh! But still, in this new framework that I was working on I didn't want to add configuration in a Javascript event, I wanted to be able to configure as much as possible via AngularJS parameters. I noticed that Azimuth already had support for strategy parameters. Unfortunately it only supported an actual strategy instance as the parameter rather than a string. I had, again, to change the Azimuth code to first search for the name of the strategy parameters in OpenLayers.Strategy and if not found to $parse the string. Yet it didn't work as expected. The clustering was not engaging. Wasting another half an hour I realised that, at least in the case of this weirdly buggy Cluster strategy, I not only needed it, but also a Fixed strategy. I've changed the code to add the strategy instead of replacing it and suddenly clustering was working fine. I still have to make it configurable, but that is a detail I don't need to go into right now. Anyway, remember that the loadend event was not fired when only the Cluster strategy was in the strategies array of the layer; I think you need the Fixed strategy to load data from somewhere.
Next thing I wanted to do was to center the map on the features existent on the map. The map also needed to be resized to the actual page size. I added a custom directive to expand a div's height down to an element which I styled to be always on the bottom of the page. The problem now was that the map was getting instantiated before the div was resized. This means that maybe I had to start with a big default height of the div. Actually that caused a lot of problems since the map remained as big as first defined and centering the map was not working as expected. What was needed was a simple map.updateSize(); called after the div was resized. In order to then center and zoom the map on the existent features I used this code:
        var bounds={
        for (var i=0; i<layer.features.length; i++) {
            var feature=layer.features[i];
            var point=feature.geometry;
            if (!isNaN(point.x)&&!isNaN(point.y)) {
        var extent=new OpenLayers.Bounds(bounds.minLon,bounds.minLat,bounds.maxLon,bounds.maxLat);

Now, while the clustering was working OK, I wanted to show stuff and make those clusters do things for me. I needed to style the clusters. This is done via:
        layer.styleMap=new OpenLayers.StyleMap({
                "default": defaultStyle,
                "select": selectStyle
            "featureselected": clickFeature


        var hover = new OpenLayers.Control.SelectFeature(
            layer, {hover: true, highlightOnly: true}
        map.addControl(hover);{"featurehighlighted": displayFeature});
 {"featureunhighlighted": hideFeature});

        var click = new OpenLayers.Control.SelectFeature(
            layer, {hover: false}
I am adding two OpenLayers.Control.SelectFeature controls on the map, one activates on hover, the other on click. The styles that are used in the style map define different colors and also a dynamic radius based on the number of features in a cluster. Here is the code:
        var defaultStyle = new OpenLayers.Style({
            pointRadius: "${radius}",
            strokeWidth: "${width}",
            externalGraphic: "${icon}",
            strokeColor: "rgba(55, 55, 28, 0.5)",
            fillColor: "rgba(55, 55, 28, 0.2)"
        }, {
            context: {
                width: function(feature) {
                    return (feature.cluster) ? 2 : 1;
                radius: function(feature) {
                    return feature.cluster&&feature.cluster.length>1
                        ? Math.min(feature.attributes.count, 7) + 2
                        : 7;
You see that the width and radius are defined as dynamic functions. But here we have an opportunity that I couldn't let pass. You see, in these styles you can also define the icons. How about defining the icon dynamically using canvas drawing and then toDataURL? And I did that! It's not really that useful, but it's really interesting:
        function fIcon(feature,type) {
                    var iconKey=type+'icon';
                    if (feature[iconKey]) return feature[iconKey];
                    if(feature.cluster&&feature.cluster.length>1) {
                        var canvas = document.createElement("canvas");
                        var radius=Math.min(feature.cluster.length, 7) + 2;
                        canvas.width = radius*2;
                        canvas.height = radius*2;
                        var ctx = canvas.getContext("2d");
                            ctx.fillStyle = this.defaultStyle.fillColor;
                        ctx.strokeStyle = this.defaultStyle.strokeColor;
                            ctx.fillStyle = this.defaultStyle.strokeColor;
                        var bounds={
                        for(var c = 0; c < feature.cluster.length; c++) {
                              var child=feature.cluster[c];
                            var x=feature.geometry.x-child.geometry.x;
                            var y=feature.geometry.y-child.geometry.y;
                        var q=0;
                        var zoom=2;
                        for(var c = 0; c < feature.cluster.length; c++) {
                              var child=feature.cluster[c];
                            var x=-(feature.geometry.x-child.geometry.x)*q+radius;
                            var y=(feature.geometry.y-child.geometry.y)*q+radius;
                                ctx.fillRect(parseInt(x-zoom/2), parseInt(y-zoom/2), zoom, zoom);
                        feature[iconKey] = canvas.toDataURL("image/png");
                    } else {
                        feature[iconKey] = OpenLayers.Marker.defaultIcon().url;
                    return feature[iconKey];

        defaultStyle.context.icon=function(feature) {
        selectStyle.context.icon=function(feature) {
This piece of code builds a map of the features in the cluster, zooms it to the size of the cluster icon, then also draws a translucent circle as a background.
I will not bore you with the displayFeature and clickFeature code, enough said that the first would set the html title on the layer element and the other would either zoom and center or display the info card for one single feature. There is a gotcha here as well, probably caused initially by the difference in size between the map and the layer. In order to get the actual pixel based on latitude and longitude you have to use map.getLayerPxFromLonLat(lonlat), not map.getPixelFromLonLat(lonlat). The second will work, but only after zooming or moving the map once. Pretty weird.

There are other issues that come to mind now, like making the URL for the data dynamic, based on specific parameters, but that's for another time.