Building a 3D Tag Cloud in Silverlight

What is this about?
In this project I'd like to show how to build a good-looking tag cloud in Silverlight.
I should admit right away that I borrowed the idea and part of the implementation from others. However, the example I found there was a bit buggy, so I decided to write this article.
Let's define the task as follows:
- display the tags on the surface of a 3D sphere (or ellipsoid);
- rotate the cloud depending on the mouse position;
- load the tag data from an XML document.
Choosing a 3D library:
Unfortunately, the latest version of Silverlight doesn't include 3D capabilities the way WPF does. Fortunately, some of the functionality we need has been written by third-party developers. This project uses the Axelerate3D library.
XAML
<UserControl x:Class="Mercury.Web.Silverlight.Tags.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid x:Name="LayoutRoot" Background="White">
<Canvas x:Name="RootCanvas" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
</Canvas>
</Grid>
</UserControl>
This is the XAML of the main class of the Silverlight application (MainPage). As you can see, it's all very simple: a single Canvas that will render the cloud.
Displaying tags: Let's move on to the class that represents a tag. It will be a label with the required text, placed at coordinates (x, y, z) relative to the center of the cloud.
public class Tag3D
{
public Tag3D(double x, double y, double z, string text, Uri uri)
{
CenterPoint = new Point3D(x, y, z);
TextBlock = new TextBlock { Text = text };
BtnLink = new HyperlinkButton
{
Content = TextBlock,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
NavigateUri = uri,
Visibility = Visibility.Collapsed
};
}
public HyperlinkButton BtnLink { get; set; }
public Point3D CenterPoint { get; set; }
private TextBlock TextBlock { get; set; }
public void Redraw(UISize size) { ... }
private void UpdatePosition(double rx, double ry, double xOffset, double yOffset) { ... }
private void UpdateLayout() { ... }
private void UpdateSize() { ... }
private void UpdateColor() { ... }
private void UpdateVisibility() { ... }
}
Next we need methods that determine the size and color of a tag:
private void UpdateSize()
{
BtnLink.FontSize = 16 + CenterPoint.Z * 4;
}
private void UpdateColor()
{
var color = 160 + CenterPoint.Z * 96;
color = Math.Max(0, color);
color = Math.Min(255, color);
BtnLink.Foreground = new SolidColorBrush(Color.FromArgb(Convert.ToByte(color), 0, 0, 0));
}
Now let's position the tag in space:
// rx, ry - the size of the cloud's ellipse (projected onto the plane)
// xOffset, yOffset - the coordinates of the cloud's center relative to the Canvas
private void UpdatePosition(double rx, double ry, double xOffset, double yOffset)
{
var maxZ = Math.Min(rx, ry);
var x = (xOffset + CenterPoint.X * rx) - (BtnLink.ActualWidth / 2.0);
var y = (yOffset - CenterPoint.Y * ry) - (BtnLink.ActualHeight / 2.0);
var z = CenterPoint.Z * maxZ;
Canvas.SetLeft(BtnLink, x);
Canvas.SetTop(BtnLink, y);
Canvas.SetZIndex(BtnLink, Convert.ToInt32(z));
}
Placing the tags on the surface of a sphere: Let's get back to the MainPage class.
The next piece of code needs a bit of math to understand. This method distributes the tags over the surface of a sphere.
private void FillTags()
{
RootCanvas.UpdateLayout();
RootCanvas.Children.Clear();
tagBlocks = new List<Tag3D>();
var length = tagList.Count;
for (var i = 1; i <= length; i++)
{
var phi = Math.Acos(-1.0 + (2.0 * i - 1.0) / length);
var theta = Math.Sqrt(length Math.PI) phi;
var x = Math.Cos(theta) * Math.Sin(phi);
var y = Math.Sin(theta) * Math.Sin(phi);
var z = Math.Cos(phi);
var tag = tagList[i - 1];
var uri = UrlHelper.GetTagUri(UrlEncode(tag));
var item = new Tag3D(x, y, z, tag, uri);
RootCanvas.Children.Add(item.BtnLink);
tagBlocks.Add(item);
}
}
Rotating the cloud: To rotate the cloud we'll use the position of the mouse cursor. The farther the cursor is from the center of the cloud, the larger the rotation angle, and therefore the faster the cloud spins.
On initialization we give it a small initial rotation:
private readonly RotateTransform3D rotateTransform = new RotateTransform3D();
public void Run()
{
CompositionTarget.Rendering += OnCompositionTargetRendering;
LayoutRoot.MouseEnter += OnLayoutRootMouseEnter;
LayoutRoot.MouseLeave += OnLayoutRootMouseLeave;
slowDownCounter = 500.0;
runRotation = true;
rotateTransform.Rotation = new AxisAngleRotation3D(new Vector3D(0.8, 0.6, 0), 0.5);
FillTags();
}
Determining the direction and speed of the rotation:
private void OnLayoutRootMouseMove(object sender, MouseEventArgs e)
{
var position = e.GetPosition(RootCanvas);
SetRotateTransform(position);
}
private void SetRotateTransform(Point position)
{
var size = GetUISizes();
var x = (position.X - size.XOffset) / size.XRadius;
var y = (position.Y - size.YOffset) / size.YRadius;
var angle = Math.Sqrt(x x + y y);
rotateTransform.Rotation = new AxisAngleRotation3D(new Vector3D(-y, -x, 0.0), angle);
}
private UISize GetUISizes()
{
return new UISize
{
XOffset = RootCanvas.ActualWidth / 2.0,
YOffset = RootCanvas.ActualHeight / 2.0
};
}
Now let's draw the tags.
private void OnCompositionTargetRendering(object sender, EventArgs e)
{
if (!(runRotation || (slowDownCounter <= 0.0)))
{
var rotation = (AxisAngleRotation3D)rotateTransform.Rotation;
rotation.Angle *= slowDownCounter / 500.0;
rotateTransform.Rotation = rotation;
slowDownCounter--;
}
if (((AxisAngleRotation3D)rotateTransform.Rotation).Angle > 0.05)
{
RotateBlocks();
}
}
private void RotateBlocks()
{
var size = GetUISizes();
foreach (var tagd in tagBlocks)
{
Point3D pointd;
if (rotateTransform.TryTransform(tagd.CenterPoint, out pointd))
{
tagd.CenterPoint = pointd;
tagd.Redraw(size);
}
}
}
Loading the list of tags:
Now we can take care of filling our cloud with tag data.
Suppose some URL returns XML with the following structure:
<?xml version="1.0" encoding="utf-8"?>
<taglist>
<tag>IIS7</tag>
<tag>.NET</tag>
<tag>Silverlihgt</tag>
<tag>3D</tag>
</taglist>
Then the code that gets the list of tags might look like this:
private void LoadTagList(Uri uri)
{
try
{
// Get the XML with the list of tags.
var request = (HttpWebRequest)WebRequest.Create(uri);
var waitingEvent = new AutoResetEvent(false);
var callback = new AsyncCallback(result => ((EventWaitHandle)result.AsyncState).Set());
var asyncResult = request.BeginGetResponse(callback, waitingEvent);
waitingEvent.WaitOne();
var response = request.EndGetResponse(asyncResult);
// Parse the XML and build the list of tags.
var doc = XDocument.Load(response.GetResponseStream());
tagList.AddRange(doc.Descendants("tag").Select(item => item.Value));
Dispatcher.BeginInvoke(Run);
}
catch
{
}
}
Done!
You can download the source code here.
HTML code:
<object width="500" height="300" type="application/x-silverlight-2" data="data:application/x-silverlight-2,">
<param name="source" value="/ClientBin/Mercury.Web.Silverlight.Tags.xap" />
<param name="background" value="white" />
<param name="minRuntimeVersion" value="3.0.40624.0" />
<param name="autoUpgrade" value="true" />
<param name="windowless" value="true" />
<param name="enableGPUAcceleration" value="True" />
<param name="initParams" value="url=/Node/Tags/{0},service=/Node/TagList" />
<a style="text-decoration: none" href="http://go.microsoft.com/fwlink/?LinkID=149156&amp;v=3.0.40624.0">
<img style="border-style: none"
src="http://go.microsoft.com/fwlink/?LinkId=108181"
alt="Get Microsoft Silverlight" />
</a>
</object>
That's all!
Only registered users can leave a comment.
Sign in or register to leave a comment.