A Silverlight TagCloud, Part 2: The TagCloud

July 29, 2009 08:36

The complete Silverlight TagCloud series of posts:

  1. A Silverlight TagCloud, Part 1: The WCF Service
  2. A Silverlight TagCloud, Part 2: The TagCloud
  3. A Silverlight TagCloud, Part 2.1: Refinements
  4. Silverlight TagCloud Now on CodePlex

This is the second part of a two part series about a Silverlight TagCloud I developed. In Part 1 of this series I discussed the WCF service component. This part will be about the Silverlight control itself.

A Brief Re-introduction to the TagCloud Architecture

The architecture is a simple producer/consumer model. The producer is a WCF service that gathers tag information. The consumer is the Silverlight control, which gets and renders those tags.

Assuming you're on the itscodingtime.com web site and not an RSS reader, you can see the TagCloud in action at the right.

Some of the Difficulties

Developing this control would have been a piece of cake if not for one thing: resizing. Think about it. The control needs to respond to resize events based on (1) browser resizing and (2) the number of tags being displayed. I was able to make use of a WrapPanel control with a TextBlock for each tag, which greatly simplified some of the work. But the WrapPanel will gladly overflow beyond its container if you let it.

Now, you might be able to get away with not worrying about #1 depending on how your web site is laid out, but #2 is a hard one to get around. That's not to say my solution is the only one or the best. There may well be a 100% width here and a 100% height there method that works (maybe even across all browsers), but I couldn't figure it out.

I did, however, come up with a control that quite nicely resizes based on browser sizing and tag information, so let's get into the good stuff.

The Silverlight TagCloud Control

First of all, as always, the full source is available for download below. In the .zip, you'll find projects for the TagCloudService (see Part 1) and TagCloudControl, as well as a basic web app that hosts the service and control and should prove useful in terms of testing. One thing of note: I commented out the BlogEngine.NET specific code in the WCF service and put in a dummy loop to generate tag data. This way you can run the project without having BlogEngine.NET in place.

The TagCloud control uses two controls from the Silverlight Toolkit: WrapPanel and TextBlock. You'll probably want to download the control toolkit if you plan on doing any debugging (though I do include System.Windows.Controls.dll in the .zip download).

Here's the project layout so you can get a feel for what files are involved:

image

Pretty standard stuff, though note that I've already added the service reference to TagCloudService and a strong name key file for the Silverlight control.

Let's get into the control's files.

1.) App.xaml

I defined one style in the Application's XAML file (per the Future Enhancements list below, I plan to add the ability to specify your own font family as well as tag color, so this style will be going away at some point).

   1: <Application xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   2:          xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" 
   3:          x:Class="TagCloud.TagCloudControl.App">
   4:     <Application.Resources>
   5:         <Style x:Key="TagStyle" TargetType="HyperlinkButton">
   6:             <Setter Property="Foreground" Value="#d0eb55" />
   7:             <Setter Property="FontFamily" Value="Tahoma" />
   8:         </Style>
   9:     </Application.Resources>
  10: </Application>

2.) App.xaml.cs

If you look in App.xaml.cs, you'll see that the control receives a small number of Initial Parameters: baseUrl, controlId, controlHostId, tagThreshold, minimumFontSize. Here's the Application_Startup method:

   1: private void Application_Startup (object sender, StartupEventArgs e)
   2: {
   3:     if (e.InitParams.Count <= 0)    // no initial param's, no continue
   4:     {
   5:         return;
   6:     }
   7:  
   8:     // NOTE: We'll look for missing params and set defaults in the Page constructor
   9:  
  10:     var baseUrl = string.Empty;
  11:     var controlId = string.Empty;
  12:     var controlHostId = string.Empty;
  13:     var tagThreshold = string.Empty;
  14:     var minimumFontSize = string.Empty;
  15:  
  16:     if (e.InitParams.ContainsKey ("BaseUrl"))
  17:     {
  18:         baseUrl = e.InitParams["BaseUrl"];
  19:     }
  20:  
  21:     if (e.InitParams.ContainsKey ("ControlId"))
  22:     {
  23:         controlId = e.InitParams["ControlId"];
  24:     }
  25:  
  26:     if (e.InitParams.ContainsKey ("ControlHostId"))
  27:     {
  28:         controlHostId = e.InitParams["ControlHostId"];
  29:     }
  30:  
  31:     if (e.InitParams.ContainsKey ("TagThreshold"))
  32:     {
  33:         tagThreshold = e.InitParams["TagThreshold"];
  34:     }
  35:  
  36:     if (e.InitParams.ContainsKey ("MinimumFontSize"))
  37:     {
  38:         minimumFontSize = e.InitParams["MinimumFontSize"];
  39:     }
  40:  
  41:     RootVisual = new Page (baseUrl, controlId, controlHostId, tagThreshold, minimumFontSize);
  42:  
  43:     return;
  44: }

And a brief description of each initial parameter:

  • baseUrl: This is the base web site url passed in via the hosting site. I have this because I've deployed the control to multiple sites, so this is an easy way to have the site identify itself. Note that you could also use Application.Current.Host.Source, then chop off the "ClientBin/Control.xap" part. I might even go that route as a future enhancement and simplification.
  • controlId: This is the Silverlight control's id from ASP.NET. We need this for sizing.
  • controlHostId: This is the hosting div's id, also from ASP.NET. We need this for sizing.
  • tagThreshold: In a TagCloud you typically only want to display those tags whose frequency has surpassed a certain threshold. That's the purpose of this parameter. Set it to 0 if you want to display all tags.
  • minimumFontSize: The minimum font size to use for the "smallest" tag. Tags are sized up proportionally from there.

All of those parameters are then handed off to the control's Page class.

3.) Page.xaml

Here's the Page's XAML:

   1: <UserControl xmlns:controlsToolkit="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls.Toolkit" x:Class="TagCloud.TagCloudControl.Page"
   2:     xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
   3:     xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   4:     Loaded="UserControl_Loaded"
   5:     xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
   6:     mc:Ignorable="d">
   7:     <controlsToolkit:WrapPanel x:Name="uxWrapPanel" SizeChanged="WrapPanel_SizeChanged" />
   8: </UserControl>

Note that I am referencing the Silverlight Control Toolkit here since we are using a WrapPanel.

4.) Page.xaml.cs

This is where all of the work is done. I won't go through every part, but instead try to hit the highlights.

a.) ScriptableMember

In the Page class constructor we register a scriptable object in order to build a JavaScript to Silverlight bridge:

   1: HtmlPage.RegisterScriptableObject ("JStoSLBridge", this);

The method UpdateControlSize is then called from ASP.NET/JavaScript and is initiated from, appropriately enough, the browser resize event:

   1: [ScriptableMember]
   2: public void UpdateControlSize (int nNewWidth)
   3: {
   4:     if (HtmlPage.Document != null)
   5:     {
   6:         var silverlightControlHost = HtmlPage.Document.GetElementById (_tagCloudControlHostId);
   7:         var silverlightObjectTag = HtmlPage.Document.GetElementById (_tagCloudControlClientId);
   8:  
   9:         if ((silverlightControlHost != null) && (silverlightObjectTag != null))
  10:         {
  11:             silverlightObjectTag.SetStyleAttribute ("width", nNewWidth + "px");
  12:             silverlightControlHost.SetStyleAttribute ("width", nNewWidth + "px");
  13:         }
  14:     }
  15:  
  16:     return;
  17: }

b.) The WCF TagCloudService Proxy

Here's where the baseUrl parameter comes in:

   1: var binding = new BasicHttpBinding ();
   2: var address = new EndpointAddress (new Uri (Application.Current.Host.Source, baseUrl + "TagCloudService.svc"));
   3: _tagCloudService = new TagCloudServiceClient (binding, address);

Turns out I'm using Application.Current.Host.Source anyway. A good cleanup step here might be to just parse out the base url from Application.Current.Host.Source and not use the baseUrl parameter at all. In any case, this initializes our connection to the TagCloud WCF service from which we will be retrieving tag data. However you construct or get the web site's address, it's best to dynamically create the WCF endpoint since it alleviates some of the labor when deploying to multiple sites.

c.) Retrieving Tag data

Once the control has been loaded we fire off an asynchronous call to the TagCloudService:

   1: private void UserControl_Loaded (object sender, RoutedEventArgs e)
   2: {
   3:     if (_tagCloudService != null)
   4:     {
   5:         _tagCloudService.GetTagsCompleted += tagCloudService_GetTagsCompleted;
   6:         _tagCloudService.GetTagsAsync (TagThreshold, BaseUrl);
   7:     }
   8:  
   9:     return;
  10: }

Of note is the TagThreshold parameter, which the service will use to only return those tags which meet the specified minimum threshold (no point in sending back all tag data if the control isn't going to make use of it).

I'll spare you the whole tagCloudService_GetTagsCompleted method; here's the main part:

   1: foreach (var cloudTag in _cloudTagCollection)
   2: {
   3:     if (cloudTag.TagOccurrences < TagThreshold)    // if tag occurrence is below threshold, do not add; the service shouldn't return these, but just in case
   4:     {
   5:         continue;
   6:     }
   7:  
   8:     if (cloudTag.Equals (null)) continue;
   9:  
  10:     // we'll wrap a TextBlock in the HyperlinkButton in order to support text wrapping
  11:     var textBlock = new TextBlock { Text = cloudTag.TagName, TextWrapping = TextWrapping.Wrap };
  12:  
  13:     var lnkBtn = new HyperlinkButton
  14:     {
  15:         Content = textBlock,
  16:         FontSize = CalcTagFontSize (cloudTag.TagOccurrences, maxOccurrences),
  17:         NavigateUri = new Uri (cloudTag.TagLink),
  18:         HorizontalAlignment = HorizontalAlignment.Stretch,
  19:         VerticalAlignment = VerticalAlignment.Stretch,
  20:         HorizontalContentAlignment = HorizontalAlignment.Center,
  21:         VerticalContentAlignment = VerticalAlignment.Center,
  22:         Style = Application.Current.Resources["TagStyle"] as Style
  23:     };
  24:  
  25:     lnkBtn.MouseEnter += TagCloudItem_MouseEnter;    // we'll do some styling on mouse over
  26:     lnkBtn.MouseLeave += TagCloudItem_MouseLeave;    // we'll restore styling on mouse leave
  27:  
  28:     uxWrapPanel.Children.Add (lnkBtn);
  29: }

We loop through the received tags, creating a new TextBlock for each and then wrapping that TextBlock in a HyperlinkButton so that when a user clicks on the tag it takes them to that series of pages.

What it's iterating through is a collection of CloudTag objects, defined as part of the TagCloudService's data contract. More on that can be found in Part 1 of this series.

d.) Calculating Tag Font Size

One of the core features of a TagCloud is, of course, the ability to display tags in varied sizes based on their frequency. Thanks goes to poeticcode for supplying the tag sizing algorithm:

   1: private double CalcTagFontSize (int tagOccurrences, int maxTagOccurrences)
   2: {
   3:     if (maxTagOccurrences == 0)    // this probably shouldn't happen, but you never know...
   4:     {
   5:         return (MinimumFontSize);
   6:     }
   7:  
   8:     // from http://poeticcode.wordpress.com/2007/01/27/tag-cloud-algorithmlogicformula/
   9:  
  10:     var percent = 150 * (1.0 + ((1.5 * tagOccurrences) - (maxTagOccurrences / 2.0)) / maxTagOccurrences);
  11:     var fontSize = (percent / 100) * MinimumFontSize;
  12:  
  13:     return (fontSize);
  14: }

Resizing the TagCloud Control

This is a big enough part of the whole control that I thought it deserved its own section. To be honest, I went back and forth trying to consolidate the resizing code either in the control or purely in JavaScript. No matter how you look at it, you really need resizing in both places. That's because we have to potentially resize the control based on browser resizing as well as the number of tags and their size. While it's possible to put the control in such a position in the browser that it's never affected by browser sizing, but you still have to deal with resizing based on tag content. The WrapPanel will resize itself, but not the actual control. Throw in browser incompatibilities and this issue wound up not being trivial at all.

Let's take a look.

1.) Control Sizing within the Control

There are two places within the control where sizing occurs. First, we have the before-mentioned UpdateControlSize:

   1: [ScriptableMember]
   2: public void UpdateControlSize (int nNewWidth)
   3: {
   4:     if (HtmlPage.Document != null)
   5:     {
   6:         var silverlightControlHost = HtmlPage.Document.GetElementById (_tagCloudControlHostId);
   7:         var silverlightObjectTag = HtmlPage.Document.GetElementById (_tagCloudControlClientId);
   8:  
   9:         if ((silverlightControlHost != null) && (silverlightObjectTag != null))
  10:         {
  11:             silverlightObjectTag.SetStyleAttribute ("width", nNewWidth + "px");
  12:             silverlightControlHost.SetStyleAttribute ("width", nNewWidth + "px");
  13:         }
  14:     }
  15:  
  16:     return;
  17: }

UpdateSize is a ScriptableMember, which means it can be called from JavaScript. Here's that scripting code from the ASP.NET page:

   1: function UpdateTagCloudSize() {
   2:  
   3:     var silverlightControl = document.getElementById('silverlightTagCloud');
   4:     if (silverlightControl == null) return;
   5:  
   6:     var sidebarWidth = jQuery("#sidebar").css("width");
   7:     var newCtrlWidth = parseInt(sidebarWidth); // removes the "px" at the end
   8:     newCtrlWidth -= 10; // some tweaking just for this site
   9:  
  10:     silverlightControl.content.JStoSLBridge.UpdateControlSize(newCtrlWidth); // reminder: don't need "px" at the end
  11: }
  12:  
  13: // addEvent function by John Resig: http://ejohn.org/projects/flexible-javascript-events/
  14: function addEvent(obj, type, fn) {
  15:     if (obj.attachEvent) {
  16:         obj['e' + type + fn] = fn;
  17:         obj[type + fn] = function() { obj['e' + type + fn](window.event); }
  18:         obj.attachEvent('on' + type, obj[type + fn]);
  19:     }
  20:     else
  21:         obj.addEventListener(type, fn, false);
  22: }
  23:  
  24: // add Resize event handler
  25: addEvent(window, 'resize', function(event) {
  26:     UpdateTagCloudSize();
  27: });

Courtesy of John Resig we have an event handler that hooks into the browser's resize event. As the browser is resized, we make a call to our UpdateControlSize via our JavaScript-to-Silverlight Bridge mechanism. Resizing challenge #1 handled.

The other place we deal with resizing is via the WrapPanel's SizeChanged event handler inside the Silverlight control:

   1: private void WrapPanel_SizeChanged (object sender, SizeChangedEventArgs e)
   2: {
   3:     if (HtmlPage.Document != null && _tagsLoaded)    // don't start resizing until content loaded
   4:     {
   5:         var silverlightControlHost = HtmlPage.Document.GetElementById (_tagCloudControlHostId);
   6:         var silverlightObjectTag = HtmlPage.Document.GetElementById (_tagCloudControlClientId);
   7:  
   8:         if ((silverlightControlHost != null) && (silverlightObjectTag != null))
   9:         {
  10:             silverlightObjectTag.SetStyleAttribute ("height", e.NewSize.Height + "px");
  11:             silverlightControlHost.SetStyleAttribute ("height", e.NewSize.Height + "px");
  12:         }
  13:     }
  14:  
  15:     return;
  16: }

We passed in the control id's from ASP.NET to the control, so that's how we know the clientId and hostId. We then modify their heights and, if not for an initial sizing issue, we'd be done. Unfortunately, I had to throw in an additional kick-start to get the control properly sized when it first loads.

2.) Kick-starting the Control Sizing with some more JavaScript/jQuery

Not that big of a deal, but I had to give the control a kick in the butt when the page is first loaded. We accomplish this initial sizing with some help from jQuery (if you're new to jQuery, don't sweat it—I included the .js file in the project and that's all you really need for this):

   1: jQuery(document).ready(function() {
   2:  
   3:     var silverlightControlHost = document.getElementById('silverlightControlHost');
   4:     var sidebarWidth = jQuery("#sidebar").css("width");
   5:     sidebarWidth = parseInt(sidebarWidth); // removes the "px" at the end
   6:  
   7:     sidebarWidth -= 10; // bit of a tweak based on the ict template, this will keep the control from overflowing the right border
   8:  
   9:     // resize the control host, NOT the control itself

  10:     silverlightControlHost.style.width = sidebarWidth + "px";
  11: });

Using jQuery's "ready" function, which is essentially an event handler for when the page is ready for user interaction, we get some width information that is very specific to this blog template. That means you'll have to tweak this code to do what you need based on your own web site layout.

Conclusion

That's the Silverlight TagCloud control. I've already come up with a short list of future enhancements below, so look for an update sometime in the future.

Until next time.

Download: Silverlight TagCloud control on CodePlex

Future Enhancements

Because there's always room for improvement.

  1. Pass in tag font family as parameter
  2. Pass in tag font color as parameter
  3. Pass in control background color as parameter
  4. Clean-up some of the sizing (if possible)

References

TagCloud Algorithm/Logic/Formula

Tag Clouds Gallery: Examples And Good Practices


Add comment


 


[b][/b] - [i][/i] - [u][/u] - [q][/q]



Preview