A Linear Clustering Algorithm for Virtual Earth

by Gong Liu May 22, 2009 05:12

Official Tracker for Katie Visco's Run Across America   

A while ago a friend showed me a piece of news about a girl named Katie Visco who is about to become the youngest female ever to run solo across America. I thought her story was very inspirational and contacted her about tracking her run on the internet. She was thrilled about the idea and I ended up hosting and maintaining the following tracker about her run. I update the tracker every day based on the mileage log published on her blog site Pave Your Lane

Technical Notes

Linear Clustering Algorithm  

The above tracker is very similar to my workout tracker. However, one key difference is that her tracker shows every day on the map while my tracker only shows my workout days. In her case, the running days are marked by a little red square  and the resting days by a little yellow square . On some resting days she may stay at the same location and thus the day markers overlap. If you hover the mouse cursor on overlapped markers, only the infobox of the top most marker is shown, no matter how closely you zoom in. In the above tracker I have solved the problem by representing overlapped markers with a bigger yellow square . When you hover the mouse cursor on the bigger marker, you will see a list of days it represents. Clicking on one of the days will show that day's infobox at the same location. The technique behind the solution is called pushpin clustering. Pushpin clustering is a process of representing several nearby pushpins with a single pushpin. The original purpose of this is to reduce overcrowded pushpins that may obscure the map underneath. We use pushpin clustering to gain access to overlapped pushpins or markers. Overcrowding is not much of a concern here. Before Virtual Earth 6.2, developers using the Virtual Earth map control would have to either divide groups of pushpins into shape layers and manually control which layers to show and hide at different zoom levels, or just not show pushpins at zoom levels if there were too many on the screen. With Virtual Earth 6.2, it becomes a lot easier to control which pushpins are clustered and how a cluster is displayed.

There are two overloaded methods related to pushpin clustering in VE 6.2:

VEShapeLayer.SetClusteringConfiguration(type, options)
VEShapeLayer.SetClusteringConfiguration(algorithm, options)

The first method allows you to choose one of the pre-defined clustering algorithms. In VE 6.2, there is only one pre-defined clustering algorithm - the grid clustering algorithm. The grid clustering algorithm divides map screen into grids and pushpins falling in one grid are represented by a single pushpin at the grid center. The second method allows you to define your own clustering algorithm. The second parameter in both methods, options, is a VEClusteringOptions object that specifies how a pushpin cluster is displayed in terms of an icon and a callback function where you put your own code to do something about each cluster, such as rendering an infobox.

In our case, the day markers are along some line features (roads), and we are only interested in making overlapped markers into clusters. So the pre-defined grid algorithm is not applicable. We have to roll out our own algorithm, which I call a linear clustering algorithm because of the unique spatial distribution of our markers.

Simply stated, any two or more consecutive markers that overlap or are close enough form a cluster. The following figures show when markers form a cluster and when they don't according to our linear clustering algorithm.
   

The current marker (black square) in A) does not belong to any cluster because none of its consecutive markers (markers immediately before and after it) is close enough to it. By close enough we mean within a pre-defined square area (or a circle). The side d of the square area is measured in degrees same as latitude/longitude, instead of pixels, and thus the clustering does not change with different zoom levels. By our definition the current marker in B) forms a cluster with its next marker. Backtracking and loop as shown in C) and D) do not form a cluster, because the two markers in the square are not consecutive.

Now let's take a look at some code snips.

    ......
    //load day markers to the third layer
    var layer3 = new VEShapeLayer(); 
    var co = new VEClusteringOptions();
    var customIcon = new VECustomIconSpecification();
    customIcon.CustomHTML = "<div style='margin-top: 10px; margin-left: 10px'><img src='images/yellowsquarelarge.gif' width='8' height='8'></div>";
    co.Icon = customIcon;
    co.Callback = ClusterCallback;
    layer3.SetClusteringConfiguration(LinearClusterAlgorithm, co);
    map.AddShapeLayer(layer3);
    var shapes = new Array();
    for (var i = 0; i < days.length; i++)
    {
        var sh = new VEShape(VEShapeType.Pushpin, days[i].CityCenter);
        sh.SetTitle(days[i].Date.toDateString() + " - Day " + (i + 1));
        if (days[i].RunningDay)
            sh.SetCustomIcon("<div style='margin-top: 11px; margin-left: 10px'><img src='images/redsquare.gif' width='5' height='5'></div>");
        else
            sh.SetCustomIcon("<div style='margin-top: 11px; margin-left: 10px'><img src='images/yellowsquare.gif' width='5' height='5'></div>");   
        sh.SetDescription(days[i].GetHTML());
        shapes[i] = sh;
    }
    layer3.AddShape(shapes);  
    ......

The above code snip shows that a shape layer is created for day markers. The SetClusteringConfiguration method indicates that the layer is configured to do clustering. The clustering algorithm is defined by a function called LinearClusterAlgorithm and any cluster is going to be dispayed according to the way specified in a VEClusteringOptions object, which includes a display icon (the bigger yellow square ) and a callback function ClusterCallback. The for loop creates day markers as an array of shapes, which is then added to the layer in a batch.

The function LinearClusterAlgorithm is defined as:

function LinearClusterAlgorithm(sLayer)
{
    var arrCSs = new Array();
    var n = sLayer.GetShapeCount();
    if (n > 0) 
    {
        var shape0 = sLayer.GetShapeByIndex(0);
        var pt0 = shape0.GetPoints()[0];
        var clusterSpec = null;
        for(var i = 1; i < n; i++)
        {
            var shape1 = sLayer.GetShapeByIndex(i);
            var pt1 = shape1.GetPoints()[0];
            if (Overlap(pt0, pt1))
            {
                if (clusterSpec == null)
                {
                    clusterSpec = new VEClusterSpecification();
                    clusterSpec.Shapes = new Array();
                    clusterSpec.Shapes.push(shape0);
                }
                clusterSpec.Shapes.push(shape1);
                clusterSpec.LatLong = pt1;
                if (i == n-1)
                    arrCSs.push(clusterSpec);   
            }
            else
            {
                if (clusterSpec != null)
                {
                    arrCSs.push(clusterSpec);
                    clusterSpec = null;
                }
            }
           
            shape0 = shape1;
            pt0 = pt1;     
        }
    }
    return arrCSs;
}

function Overlap(pt0, pt1)
{
    var dd = 0.0001;
    return (Math.abs(pt0.Latitude - pt1.Latitude) < dd && 
            Math.abs(pt0.Longitude - pt1.Longitude) < dd);
}

Here, sLayer is a VEShapeLayer object that the algorithm is going to apply to. The main part of the algorithm consists of a for loop that compares first two consecutive shape points, points 0 and 1, then points 1 and 2, and so on. If any two consecutive points overlap, they form a cluster and are added to the Shapes property of a VEClusterSpecification object. Otherwise, the VEClusterSpecification object is added to an array, which is returned to caller at the end of the loop. The function Overlap is used to determine if two points overlap. "Overlap" does not necessarily mean the two points have to have the same coordinates, but rather their differences are within a pre-defined range.

The ClusterCallback function is as follow:

function ClusterCallback(CSs)
{
    for (var i=0; i < CSs.length; ++i)
    {
        var clusterSpec = CSs[i];
        var clusterShape = clusterSpec.GetClusterShape();
        var shapes = clusterSpec.Shapes;
        clusterShape.SetTitle(shapes.length + " Days");
        var html = "";
        for (var j = 0; j < shapes.length; j++)
        {
            var shape = shapes[j];
            var icon = (shape.GetDescription().indexOf("alt='run'") > 0)? "redsquare.gif" : "yellowsquare.gif";
            html += "<img border='1' src=\"Images/" + icon + "\"> ";
            html += "<a href=\"Javascript: map.HideInfoBox(); ShowInfo('" + shape.GetID() + "')\">" + shape.GetTitle() + "</a><br>"
        }
        clusterShape.SetDescription(html);
    }
}

It accepts an array of VEClusterSpecification objects as parameter, each of which represents a cluster. Each cluster has at least two shape points. The inner for loop uses the info of these points to construct a html snip that is to be used as the description of the cluster's infobox.

When there are overlapped day markers, the touring feature is affected. This is because the ShowInfoBox method is normally called immediately after the map is panned from one point to the next (in the onendpan event handler). But if two or more points overlap, there is no pan action and the onendpan event will not fire, and thus the corresponding infoboxes will not show. To overcome this, we have to call the handler directly at the time an overlap is detected.

Image Overlay

The above tracker has a title image along the top side. Notice that the image is on top of the map layer but under the map control panel, and that the portion of the map that is under the image is still responsive to mouse drag. To overlay the image, I add an image tag just before the myMap div tag:

<img src="images/title.jpg" id="overlay" style="z-index:50; opacity: 0.7; filter: alpha(opacity=70); position:absolute; left:0px; top:0px" alt="pave your lane" />
<div id="myMap" style="width:800;height:600;position:relative;"></div> 

The style opacity: 0.7 works for Firefox and filter: alpha(opacity=70) works for IE 7.0+. So I use both. They make the image semi-transparent. z-index must be > 0, otherwise the image is put behind the map and thus can't be seen. 

The problem with the image tag itself is that it sits on top of both the map layer and the map control panel and make them not responsive to mouse actions. To solve this problem we have to add the image tag as a child node of the myMap div tag:

var veLayer = document.getElementById('myMap');
var imageOverlay = document.getElementById('overlay');
veLayer.appendChild(imageOverlay);  

Finally we have to make sure that the z-index of the image tag is between 1 and 99. A z-index value greater than 99 will put the title image above the map control panel, making it unusable.

Another possible way to overlay an image is to use a custom layer, a feature newly available in VE 6.2.  I tried this way but was unable to anchor the image to the top left corner - the image moves as the map is panned. If you know how to do this using custom layer, please let me know.

Virtual Earth Workout Tracker - Part II

by Gong Liu March 31, 2009 00:45

In Part I, I decribed what the Virtual Earth Workout Tracker was, and how it could be a useful tool for tracking one's workout and motivating him to keep going. In this part, I'll describe how to implement the Workout Tracker with Virtual Earth SDK.

Generating the Route

Normally, you want to generate a route that is closely resembling the physical thing on the ground, whether it's a highway or things like the Greate Wall of China. There are basically two ways to generate a route: trace the route manually with the aid of some mapping tool, or generate it automatically with a route engine. The following screenshot shows how you could trace a stretch of highway, say Route 66, using Google Earth.

Google Earth will save the route you are tracing in KML format, which is just XML understandable by any XML parser. As to how many points you need, depending on the length and geometry of the route, a few hundread to a couple of thousand points seem to be a good number. You need more points to trace the route more closely, but it will take longer time to get it done and longer computing time to render the route.

If your route is along drivable roads, you could probably generate the route automatically using some route engine, such as the one in Virtual Earth or Google Earth. One issue with a route engine is that it has its own mind. The route it genetares may not include all the roads you want. In this case, you will need to set up a multi-waypoint route. The waypoints provide some sort of a guideline on how the route should be generated. In Virtual Earth, once a route is generated, you can get all the points that describe the shape of the route with the VERoute.ShapePoints property. Note that this property is only available to licensed Virtual Earth developers.

If you generate the route with Google Earth, you get all the details about the route in a KML file, something like:

<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://earth.google.com/kml/2.2">
<Document>
 <name>KmlFile</name>
 <Style id="roadStyle">
  <LineStyle>
   <color>7fcf0064</color>
   <width>6</width>
  </LineStyle>
 </Style>
 <Placemark>
  <name>Route</name>
  <description>
   <![CDATA[Distance: 3,034 mi Map data 2009 Maponics, Sanborn, Tele Atlas]]>
  </description>
  <styleUrl>#roadStyle</styleUrl>
  <MultiGeometry>
   <LineString>
    <coordinates>
      -118.38541,33.83817,0 -118.38331,33.83788,0 -118.38157,33.83755000000001,0 -118.38086,33.83751,0 ......
    </coordinates> 
   </LineString>
  </MultiGeometry>
 </Placemark>
</Document>
</kml>

Polyline Reduction and Douglas-Peucker Algorithm

For a long route generated with a route engine, the number of points can be huge. For example, a coast-to-coast cross country route generated with Google Earth contains some 12,500 points. To draw the route with so many points would cause significant delay when the Workout Tracker is started. To improve the performance, we need to reduce the number of points, but at the same time maintain the general shape of the route. This type of problem is called polyline reduction or polyline simplification. One of the best-known algorithms for polyline reduction is the Douglas-Peucker algorithm. The following series of diagrams from McNeel Wiki best illustrate how the algorithm works:   

 

 

 

I found a C# implementation of the algorithm here. I modified the code to accept latitude/longitude points instead of integer points. I also added to the algorithm an interface that reads a polyline (route) from a KML file and writes the reduced polyline to a javascript file in form of a javascript array: 

var route = new Array(
new VELatLong(33.83817,-118.38541),
new VELatLong(33.83748,-118.35352),
new VELatLong(33.85775,-118.35359),
......
);

The screenshot below is the interface to the polyline reduction algorithm:

The single parameter that controls which points are removed and how many points are removed is the tolerance, which is the maximum allowable distance from a point to the nearest line segment (the dashed blue lines in the above series of diagrams). The total route distance can also serve as a crude measurement about how well a reduction is. The following table shows the point count and total distance vs. the tolerance for my cross country route:

The data in the table shows that as the tolerance increases, the number of points needed to describe the shape of the route decreases. In extreme, when the tolerance is big enough, only 2 points, the starting point and the ending point, are needed. Notice that as the tolerance increases, the route distance becomes shorter and shorter as compared to the original route distance. In my case, I use the route with 614 points (tolerance = 0.005°). This represents a 95% point reduction, while the route distance is only about 32 miles shorter or 1% shorter than the original route. Even at this level of reduction, you won't notice the discrepancy between the orginal route and reduced route up to about zoom level 9 (Virtual Earth has total 19 zoom levels, with the zoom level 19 representing the closest level to the ground). If you zoom in closer, you will see the difference, though. 

You may prefer a route with less reduction and thus less discrepancy, but you need to strike a balance between accuracy and the loading speed.

Implementation

There are 4 layers of data on the map. The first layer contains the route (cyan color). The second layer contains the progress polyline (red color). The third layer contains the workout day markers (red little requares). The fourth layer contains the starting point (green pushpin) and the ending point (red pushpin).

The route is loaded on the map with this script:

function LoadRoute()
{
    var layer = new VEShapeLayer();   //layer 1
    map.AddShapeLayer(layer); 
    var shape = new VEShape(VEShapeType.Polyline, route);
    shape.HideIcon();
    shape.SetLineColor(new VEColor(0,255,255,0.5));
    shape.SetFillColor(new VEColor(0,255,255,0.5));
    shape.SetLineWidth(5);
    layer.AddShape(shape);        

Here, route is the javascript array generated by the polyline reduction program mentioned earlier.

For simplicity, we use the following 2D javascript array to hold our workout data:

var data = new Array(
//Date, Comment, Fitness Machine, Mileage, Calories, Fitness Machine, Mileage, Calories, ...
new Array("2/24/2009", "A Long Journey Begins with the First Step", "Stationary Bike", 9, 150, "Stair Master", 2.3, 280),
new Array("2/25/2009", "", "Stationary Bike", 10, 170, "Elliptical Trainer", 1.7, 168, "Treadmill", 3, 340),
new Array("2/26/2009", "", "Stationary Bike", 9, 150, "Stair Master", 2.3, 280),
new Array("2/27/2009", "", "Stationary Bike", 9, 150, "Treadmill", 3, 350),
......
);

Each row in the above array represents a workout day, and each workout day has a date field, a comment field, and one or more workout items with each workout item containing such information as fitness machine used, distance covered, and calories burned. Of course, in a real application the workout data is most likely stored in a backend database.

To facilitate the manipulation of the workout data, we further define the following two javascript objects, WorkoutDay and WorkoutItem:

function WorkoutDay(dt, comment, items) {
    this.Date = dt;
    this.Comment = comment;
    this.Items = items;
    this.Point = null;
    this.CumulatedDistance = 0;
    this.CumulatedCalories = 0;
    this.GetDayMiles = function() {
        var m = 0;
        for(var i = 0; i < items.length; i++)
            m += items[i].Miles;
        return m;
    };
    this.GetDayCalories = function() {
        var c = 0;
        for(var i = 0; i < items.length; i++)
            c += items[i].Calories;
        return c;
    };
    this.GetHTML = function() {
        var s = "<table cellspacing='0' cellpadding='2' border='0' width='200'>";
        for(var i = 0; i < items.length; i++)
        {
            s += "<tr>";
            s += "<td>Type:</td>";
            s += "<td><img src='Images/" + items[i].GetTypeImage() + "' alt='" + items[i].Type + "'></td>";
            s += "</tr>";
            s += "<tr>";
            s += "<td>Distance:</td>";
            s += "<td>" + items[i].Miles + " miles</td>";
            s += "</tr>";
            s += "<tr>";
            s += "<td>Calories:</td>";
            s += "<td>" + items[i].Calories + "</td>";
            s += "</tr>";
            s += "<tr>";
            s += "<td colspan='2'><hr></td>";
            s += "</tr>";
        }
        s += "</table>";
        s += "<table cellspacing='0' cellpadding='2' border='0' width='200'>";
        s += "<tr><td>Day total dist:</td><td>" + this.GetDayMiles() + " miles</td></tr>";
        s += "<tr><td>Day total calories:</td><td>" + this.GetDayCalories() + "</td></tr>";
        s += "<tr><td>Cumulated dist:</td><td>" + this.CumulatedDistance.toFixed(1) + " miles</td></tr>";
        s += "<tr><td>Cumulated calories:</td><td>" + this.CumulatedCalories.toFixed(1) + "</td></tr>";
        if (this.Comment != "")
        {
            s += "<tr><td colspan='2'><hr></td></tr>";
            s += "<tr><td colspan='2'>" + this.Comment + "</td></tr>";
        }
        s += "</table>";
        return s;
    };
}

function WorkoutItem(type, miles, calories) {
    this.Type = type;
    this.Miles = miles;
    this.Calories = calories;
    this.GetTypeImage = function() {
        // Replace " " with ""
        var re = / /g;
        var r = type.replace(re, "");
        return r.toLowerCase() + ".gif";
    };

The WorkoutDay.Items property is an array of WorkoutItem objects. The WorkoutDay.GetHTML method generates a HTML snip to be presented in the InfoBox of the workout day marker. It includes such info as the workout activities and statistics for the current workout day. The WorkoutDay.CumulatedDistance property represents the cumulated workout mileage from all previous workout days through the current workout day. The WorkoutDay.Point property is a VELatLong object that marks the position of the current workout day along the route.

Referring to the above diagram, P0 ... Pn are route points. Q is the WorkoutDay.Point, and its coordinates can be determined in the following way:

  • Calculate WorkoutDay.CumulatedDistance, which is denoted by cd
  • Calculate the cumulated route distance from P0 through Pi-1, which is denoted by cdr
  • Calculate the distance between Pi-1 and Pi, which is denoted by D
  • Whether Q is in between Pi-1 and Pi is determined by this condition: cdr < cd < cdr + D 
  • Calculate d = cd - cdr
  • Calculate the coordinates of Q by linear interpolation: 
      

Finally, we are ready to show the script for loading the second, third and fourth layers of data:

function LoadData()

    //populate an array of WorkoutDay objects with workout data
    workoutdays = new Array(data.length);
    for (var i = 0; i < data.length; i++)
    {
        var items = new Array();
        for (var j = 2; j < data[i].length; j += 3)
        {
            var item = new WorkoutItem(data[i][j], data[i][j+1], data[i][j+2]);
            items.push(item);
        }
        var dt = new Date(Date.parse(data[i][0]));
        var comment = data[i][1];
        var wd = new WorkoutDay(dt, comment, items);
        workoutdays[i] = wd;
    }
    //Calculate cumulated mileage, calories and workout day position
    for (var i = 0; i < workoutdays.length; i++)
    {
        if (i == 0)
        {
            workoutdays[i].CumulatedDistance = workoutdays[i].GetDayMiles();
            workoutdays[i].CumulatedCalories = workoutdays[i].GetDayCalories();
        }
        else
        {
            workoutdays[i].CumulatedDistance = workoutdays[i-1].CumulatedDistance + workoutdays[i].GetDayMiles();
            workoutdays[i].CumulatedCalories = workoutdays[i-1].CumulatedCalories + workoutdays[i].GetDayCalories();
        }
        workoutdays[i].Point = GetProgressPoint(workoutdays[i].CumulatedDistance);
    }
   
    //load progress polyline to the second layer
    var layer2 = new VEShapeLayer(); 
    map.AddShapeLayer(layer2);
    var points = new Array();
    for (var i = 0; i < lastDayIndex; i++)
        points[i] = route[i];
    points.push(workoutdays[workoutdays.length-1].Point);
    var shape = new VEShape(VEShapeType.Polyline, points);
    shape.HideIcon();
    shape.SetLineColor(new VEColor(255,0,0,0.5));
    shape.SetFillColor(new VEColor(255,0,0,0.5));
    shape.SetLineWidth(7);
    layer2.AddShape(shape);      
   
    //load workout day markers to the third layer
    var layer3 = new VEShapeLayer(); 
    map.AddShapeLayer(layer3);
    var shapes = new Array();
    for (var i = 0; i < workoutdays.length; i++)
    {
        var sh = new VEShape(VEShapeType.Pushpin, workoutdays[i].Point);
        sh.SetTitle(workoutdays[i].Date.toDateString() + " - Day: " + (i + 1));
        sh.SetCustomIcon("<div style='margin-top: 11px; margin-left: 10px'><img src='images/redsquare.gif' width='5' height='5'></div>");
        sh.SetDescription(workoutdays[i].GetHTML());
        shapes[i] = sh;
    }
    layer3.AddShape(shapes);
   
    //load starting and ending points to the fourth layer
    var layer4 = new VEShapeLayer(); 
    map.AddShapeLayer(layer4);
    var startPt = new VEShape(VEShapeType.Pushpin, route[0]);
    startPt.SetTitle(od[0][0]);
    startPt.SetCustomIcon("<div style='margin-top: -31px; margin-left: -5px'><img src='images/" + od[0][1] + "'></div>");
    startPt.SetDescription(od[0][2]);
    startPt.SetMoreInfoURL(od[0][3]);
    layer4.AddShape(startPt);
    var endPt = new VEShape(VEShapeType.Pushpin, route[route.length - 1]);
    endPt.SetTitle(od[1][0]);
    endPt.SetCustomIcon("<div style='margin-top: -32px; margin-left: 6px'><img src='images/" + od[1][1] + "'></div>");
    endPt.SetDescription(od[1][2]);
    endPt.SetMoreInfoURL(od[1][3]);
    layer4.AddShape(endPt);
}

function GetProgressPoint(cd)
{
    var pt1 = route[0];
    var pt2;
    var cdr = 0;
    for (var i = 1; i < route.length; i++)
    {
        pt2 = route[i];
        var D = GetDistance(pt1, pt2);
        if (cdr + D > cd)
        {
            var d = cd - cdr;
            var k = d/D;
            lastDayIndex = i;
            return new VELatLong(pt1.Latitude + k * (pt2.Latitude - pt1.Latitude), pt1.Longitude + k * (pt2.Longitude - pt1.Longitude));
        }
        cdr += D;
        pt1 = pt2;
    }
    lastDayIndex = route.length - 1;
    return pt2; //last route point or destination
}

function GetDistance(pt1, pt2)
{
    var EarthRadiusInMiles = 3963.23;
    var RadiansPerDegree = 0.017453292519943295;
    return EarthRadiusInMiles * RadiansPerDegree * Math.sqrt((pt2.Latitude - pt1.Latitude) * (pt2.Latitude - pt1.Latitude) +
            Math.pow((pt2.Longitude - pt1.Longitude) * Math.cos(RadiansPerDegree * 0.5 * (pt1.Latitude + pt2.Latitude)), 2));
}

The above formula is used in the function GetProgressPoint(). The distance formula in GetDistance() was discussed in this post.

The touring/slide show feature was discussed in this post.

Further Improvements

Here are some ideas for further improvements on the Workout Tracker:

  • Use a database to store the workout data
  • Provide a web interface to allow a user to enter his workout data into the database
  • Provide a mobile interface so that a user can capture his workout data in the gym and send it out to the server via his cell phone
  • Support a group of users

Implementation of Touring Feature for Virtual Earth

by Gong Liu February 21, 2009 20:52

If you have ever used Google Earth, you will probably be familiar with the touring feature, which plays back a series of points, i.e. putting a point on the map center, opening its balloon which may contain photos and/or text, staying for a fixed time period, and then moving on to the next point. It is almost like a slide show, but at the same time shows the location of current point on the map. Virtual Earth does not have the touring feature built-in. However, we can implement the feature ourselves using Virtual Earth SDK. Please first visit this link to see a demo of the touring feature in Virtual Earth, and then read the rest of the post to see how it is implemented.

In the demo, there are 5 pushpins and their data is defined by the following 2D array:

    var data = new Array(
        new Array(34.042182, -118.232889, "homer0.jpg", 289, 300, "Homer screaming"), 
        new Array(34.045917, -118.239462, "homer1.gif", 290, 267, "D'OH!"),   
        new Array(34.049360, -118.245736, "homer2.jpg", 210, 210, "Homer having a donut"), 
        new Array(34.051448, -118.252511, "homer3.jpg", 300, 400, "Homer having a beer"), 
        new Array(34.057442, -118.259504, "homer4.jpg", 219, 167, "Homer choking Bart") );

where each pushpin has these fields: latitude, longitude, image name, image width, image height and caption. Of course, in a real application the data will probably come from a back end database. The data is loaded to the map with this script:

    function LoadData()
    {  
        var layer = new VEShapeLayer();
        map.AddShapeLayer(layer);
        //create array of VEShape and bulk add to the layer
        var shapes = new Array();
        for (var i = 0; i < data.length; i++)
        {
            var shape = new VEShape(VEShapeType.Pushpin, new VELatLong(data[i][0], data[i][1]));
            shape.SetTitle(data[i][2]);
            var desc = "<div style=\"width:200px\"><img src=\"photos/" + data[i][2] + "\" width=\"200\" height=\"" + Math.round(200 * data[i][4]/data[i][3],0) + "\" border=\"0\"></div><div style=\"width:200px\">" + data[i][5] + "</div>";
            shape.SetDescription(desc);
            shapes[i] = shape;
        }
        layer.AddShape(shapes);  
        AdjustView(layer);
    }

This script first creates a layer, then creates an array of shapes (pushpins) based on the data, and then adds the pushpins to the layer in bulk. Notice that an image width is fixed to 200 pixels and the infobox width is fixed to 233 pixels accordingly. This is to prevent a too big infobox from showing. In other words, images may be dynamically resized to fit the infobox.

Once the data is loaded, the following script is used to adjust the map view so that all the pushpins in the layer are visible on the map:

    function AdjustView(layer)
    {
        var rect = layer.GetBoundingRectangle();
        var count = layer.GetShapeCount();
        map.SetMapView(rect);
        if (count <= 1)
        {
            map.SetZoomLevel(7);
        }
    } 

The following script and variables are directly related to playing a tour:

    var currShapeIdx = -1;
    var theShape = null;
    var pause = true;
    var customBehavior = false;

    function Start()
    {
        currShapeIdx = 0;
        pause = false;
        Play();
    }

    function Stop()
    {
        currShapeIdx = -1;
        pause = true;
    }

    function Pause()
    {
        pause = true;
    }
            
    function Restart()
    {
        pause = false;
        Play();
    }
            
    function Play()
    {
        var layer = map.GetShapeLayerByIndex(1);  
        var n = layer.GetShapeCount();
        var img = document.getElementById("currImg");
        if (currShapeIdx >= 0 && currShapeIdx < n && !pause)
        {
            var sideWidth = (typeof( window.innerWidth ) == 'number' )? Math.round(window.innerWidth * 0.2) : Math.round(document.documentElement.clientWidth * 0.2);  
            sideWidth -= 10;     
            img.src = "photos/" + data[currShapeIdx][2];
            img.width = (data[currShapeIdx][3] > sideWidth)? sideWidth : data[currShapeIdx][3];
            img.style.visibility = "visible";  
            theShape = layer.GetShapeByIndex(currShapeIdx);
            var pts = theShape.GetPoints();
            customBehavior = true;
            map.PanToLatLong(pts[0]);
            //map.SetCenter(pts[0]);
            currShapeIdx++;
            var chkLoop = document.getElementById("loop");
            if (currShapeIdx == n && chkLoop.checked)
                currShapeIdx = 0;
            var selInterval = document.getElementById("interval");
            var i = selInterval.options[selInterval.selectedIndex].value;
            window.setTimeout("Play()", 1000 * i);  
        }
        else
        {
            img.style.visibility = "hidden";
            theShape = null;
        }
    }
            
    function OnMapMoved()
    {
        if (customBehavior)
            window.setTimeout("map.ShowInfoBox(theShape)", 10);
        customBehavior = false;
    }

Here are some key points about this script. First, the Play() function is called recursively via the javascript function window.setTimeout(). The calling interval is specified by the user. Second, each time the Play() function is called, the current pushpin is retrieved and the map is moved via the VE method PanToLatLong() so that the current pushpin is at the map center. The current pushpin is tracked by the variables currShapeIdx and theShape. Third, when the map is moved in position, the OnEndPan event is fired and the event handler OnMapMoved() is executed, which opens the infobox of the current pushpin. It is important to call the ShowInfoBox() method via the javascript function window.setTimeout(), and only call it once right after the Play() function is called. Fourth, the pause/restart status is tracked by the boolean variable pause. Once the touring is started, pressing the pause/restart button toggles the call between Pause() and Restart() functions. Finally, the width of the image on the sidebar is determined by the width of the sidebar or the original image width, whichever is smaller. The width of the sidebar is 20% of the width of the browser window.

Again, to see the touring feature in action and the complete javascript code, click here.

Note if you set the interval too small while the points are too far apart, you may find that some points are skipped during touring because it takes too long time to pan from one point to the next. In this case you will have to either increase the playback interval or zoom out the map a few notches.

IE Context Menu Extension

by Gong Liu January 31, 2009 05:11

Years ago I stumbled upon the concept of IE Context Menu Extension. I created a menu extension to help me to lookup an online dictionary for any highlighted word. Here is how I use it. Wheneve I encounter an unfamiliar word while browsing the web, I just highlight the word, right click on it, and select a custom context menu item "Dictionary Lookup". This will launch a new browser window and take me straight to the online dictionary with the word already looked up. Without the extension I would have to do all this menually - highlight the word, right click, copy it, launch a new browser window, type in the URL of the online dictionary, paste the word, and press a Lookup button. So by comparison my little extension came in really handy. I have been using it ever since.

Over the years I added a few more items that I thought were also very useful. These include:

  • Google highlighted word or words
  • Map highlighted address
  • Lookup highlighted word in Wikipedia
  • Translate highlighted word in a different language
  • Share a web page on Facebook, Digg, etc.   

Now let's take a look at how to implement these extensions.

Google Highlighted Word or Words

We first need to create a HTML page and save it as, say, C:\MyScripts\IE_MenuExt\goto_google.htm. The HTML page contains just this javscript:

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var text = doc.selection.createRange().text;
    var url = "http://www.google.com/search?hl=en&q=" + escape(text);
    var w = window.open(url, "newwin");
    w.focus();
</script>

This script simply gets the highlighted text, creates a Google search URL out of it, and opens the URL in a new browser window. Another version of the script is to quote the highlighted text, which instructs the Google search engine to search the exact phrase:

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var text = doc.selection.createRange().text;
    var url = "http://www.google.com/search?hl=en&q=\"" + escape(trim(text)) + "\"";
    var w = window.open(url, "newwin");
    w.focus();
</script>

<script language="javascript">
//source: http://blog.stevenlevithan.com/archives/faster-trim-javascript
function trim(str) {
    var str = str.replace(/^\s\s*/, ''),
    ws = /\s/,
    i = str.length;
    while (ws.test(str.charAt(--i)));
        return str.slice(0, i + 1);
}
</script>

The only difference in this script from the first one is that the highlighted text is trimmed and quoted. The trim function is taken from Flagrant Badassery's blog.

The second step is to create the following registry key and save it to a .reg file, such as C:\MyScripts\IE_MenuExt\install.reg:

[HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Google It]
@="file://C:\\MyScripts\\IE_MenuExt\\goto_google.htm"
"Contexts"=dword:00000010

The "Contexts" value 0x00000010 indicates that the context menu item "Google It" appears when you right click a highlighted (or selected) text in IE browser.

Map Highlighted Address

The script (C:\MyScripts\IE_MenuExt\goto_google_maps.htm):

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var text = doc.selection.createRange().text;
    text = trim(text).replace(/\n/, ", ");
    var url = "http://maps.google.com/maps?f=q&source=s_q&hl=en&q=" + escape(text);
    var w = window.open(url, "newwin");
    w.focus();
</script>

Since the highlighted address may be in multiple lines, the above script will replace any new line characters with commas to make it a single liner, and then feed it to Google Maps.

The registry key:

[HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Map It]
@="file://C:\\MyScripts\\IE_MenuExt\\goto_google_maps.htm"
"Contexts"=dword:00000010

Note: when you use the "Map It" menu extension, make sure you only highlight the address part, not anything more. Otherwise, you may confuse the Goggle Maps geocoder.

Lookup Highlighted Word in Wikipedia

The script (C:\MyScripts\IE_MenuExt\goto_wikipedia.htm):

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var text = doc.selection.createRange().text;
    text = trim(text).replace(/ /, "_");
    var url = "http://en.wikipedia.org/wiki/" + text;
    var w = window.open(url, "newwin");
    w.focus();
</script>

The key point here is that if multiple words are highlighted, they need to be concatenated with an underscore "_". On Wikipedia site if an article is not found with the extact words, you will be presented with a link to search the words. So you don't need a separate menu item to search Wikipedia.  

The registry key:

[HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Go Wikipedia]
@="file://C:\\MyScripts\\IE_MenuExt\\goto_wikipedia.htm"
"Contexts"=dword:00000010

Translate Highlighted Word in a Different Language

I have been teaching myself Spanish recently, and I find this Spanish online dictionary is very useful. It translates both ways, Spanish -> English and English -> Spanish. Now it becomes a permanent part of my menu extension collection.

The script (C:\MyScripts\IE_MenuExt\goto_spanish.htm):

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var theWord = doc.selection.createRange().text;
    var url = "http://www.spanishdict.com/translate/" + theWord;
    var w = window.open(url, "newwin");
    w.focus();
</script>

The registry key:

[HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Spanish Lookup]
@="file://C:\\MyScripts\\IE_MenuExt\\goto_spanish.htm"
"Contexts"=dword:00000010

Share a Web Page on Facebook

If you find a web page interesting and want to share it on Facebook, Digg, Reddit, or whatever online community you belong to, you only need to click an approprate sharing icon (similar to those found at the end of this post) - if the page offers one. However, if the page does not have a sharing icon, you can use the following menu extension to do the trick.  

The script (C:\MyScripts\IE_MenuExt\goto_facebook.htm):

<script language="javascript" defer>
    var doc = external.menuArguments.document;
    var u = doc.URL;
    var t = doc.title;
    var url= "http://www.facebook.com/sharer.php?u=" + escape(u) + "&amp;t=" + escape(t);
    var w = window.open(url, "newwin");
    w.focus();
</script>

Note: here I only use Facebook as an example. If you are into other online communities, simply change the URL in the script. 

The registry key:

[HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Share on Facebook]
@="file://C:\\MyScripts\\IE_MenuExt\\goto_facebook.htm"
"Contexts"=dword:00000001

The "Contexts" value 0x00000001 means the default context. I.e. the "Share on Facebook" context menu item appears when you right click anywhere on the page (except links, images, etc.). No highlighting text is necessary.

More Complex Example - Hook up a Sudoku Solver

The above menu extensions are really simple ones. In fact, we can hook up any program logic we like with a menu extension. For example, I like to go to my favorite Sudoku wbsite to get my brain teased. However, at times I got frustrated with some harder games and hoped to see them solved right away. So I hooked up a Sodoku solver I found here with a menu extension. Now whenever I have the urge to see the solution of a game, I just right click and select the solver (see screenshots below).

  

  

Install and Uninstall Menu Extensions

You can download all the javascript and registry key files discussed in this post below.

IE_MenuExt.zip (6.33 kb)

To install all the menu extensions, just copy the IE_MenuExt folder to C:\MyScripts\ and double click the install.reg file. Note: if you prefer a different installation location, you need to edit the install.reg file before running it.

To uninstall all the menu extensions, double click the uninstall.reg file. The uninstall.reg file looks like this:

REGEDIT4
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Dictionary Lookup]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Thesaurus Lookup]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Spanish Lookup]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Google It]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Google It quoted]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Go Wikipedia]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Map It]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Search Images]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Share on Facebook]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Digg It]
[-HKEY_CURRENT_USER\Software\Microsoft\Internet Explorer\MenuExt\Sudoku Solver]

About

A seasoned computer professional. A tofu culture evangelist...
more >>

Tag Cloud

Calendar

<<  April 2017  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
24252627282930
1234567

View posts in large calendar
Copyright © 2008-2011 Gong Liu. All rights reserved. | credits | contact me
The content on this site represents my own personal opinions, and does not reflect those of my employer in any way.