You've seen them. Maybe you like them, maybe not, but Tag clouds are here to stay (at least until someone invents something better). This article details how the tag cloud for this web site was created.
This site uses Hibernate (with Annotations) and Spring extensively, but while this article will make use of those frameworks, the same general concepts apply no matter what frameworks you choose to use (or not use).
The first step to bulding a Tag cloud is being able to map Articles to Tags (and vice-versa, which will be clear in a moment). The relevant code in
Article.java
is as follows:
private List<Tag> tags; @ManyToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }) @JoinTable( name = "article_tag_link", joinColumns = { @JoinColumn(name = "article_id") }, inverseJoinColumns = @JoinColumn(name = "tag_id")) @OrderBy("displayName") public List<Tag> getTags() { return tags; }
It might not be immediately obvious, but we need to map Tag objects to Article objects as well, in order to make our Hibernate queries easier to write. The following code in Tag.java accomplishes this:
private transient List<Article> articles; @ManyToMany( cascade = { CascadeType.PERSIST, CascadeType.MERGE }, mappedBy = "tags") @OrderBy("creationDate DESC") public List<Article> getArticles() { return articles; }
There are a few points to note about this code. The
@ManyToMany
and
@JoinTable
annotations are used to set up the link table containing the primary keys for each entity (in this case
article_tag_link
). Many-to-many relationships in Hibernate need an owning side. The
@JoinTable
and
mappedBy
entries in Article.java and Tag.java respectively establish the Article as the owning side. In fact, the
articles
collection in the Tag class is effectively read-only; no changes there will be persisted to the database.
Also, since the
articles
collection is not necessary for updates, we mark it as
transient
so that when serializing our Tag class we don't pull in all the articles as well.
Now that our relationship is defined, we can create some named Hibernate queries to give us some useful information for our tag cloud. From Tag.java:
@NamedQueries ({ @NamedQuery( name = "Tag.All", query = "from Tag t order by t.displayName"), @NamedQuery( name = "Tag.CountAll", query = "select count(t.id) from Tag t"), @NamedQuery( name = "Tag.ByName", query = "from Tag t where t.name = ?"), @NamedQuery( name = "Tag.AllTagStatistics", query = "select t, t.articles.size from Tag t order by t.displayName"), @NamedQuery( name = "Tag.MostArticles", query = "select max(t.articles.size) from Tag t") })
To make use of these queries, we define a few DAO interfaces:
public interface TagDaoBase { public List<TagStatistics> queryAllTagStatistics(); public List<TagStatistics> queryAllTagStatisticsInRange(int start, int limit); public int queryMostArticles(); }
public interface TagDao extends GenericDao<Tag, Long>, TagDaoBase { public Tag findByName(String name); public List<Tag> listAll(); public List<Tag> listAllInRange(int start, int limit); public int countAll(); }
public class TagStatistics implements Serializable { private Tag tag; private int articleCount; public TagStatistics() {} public TagStatistics(Tag tag, int articleCount) { this.tag = tag; this.articleCount = articleCount; } public Tag getTag() { return tag; } public void setTag(Tag tag) { this.tag = tag; } public int getArticleCount() { return articleCount; } public void setArticleCount(int articleCount) { this.articleCount = articleCount; } }
We split our DAO into two interfaces because our use of
GenericDao
allows us to avoid implementing the methods in
TagDao
directly (they will be provided automatically by Spring using interceptors, which is beyond the scope of this article). However, this approach will not work with the methods in
TagDaoBase
.
The implementation of these methods resides in
TagDaoImpl.java
:
public class TagDaoImpl extends HibernateDao<Tag, Long> implements TagDaoBase { public TagDaoImpl() { super(Tag.class); } public List<TagStatistics> queryAllTagStatistics() { return queryAllTagStatisticsInRange(0, 0); } public List<TagStatistics> queryAllTagStatisticsInRange( int start, int limit) { Query query = getSession() .getNamedQuery("Tag.AllTagStatistics"); if (start > 0) query.setFirstResult(start); if (limit > 0) query.setMaxResults(limit); List results = query.list(); List<TagStatistics> tagStats = new ArrayList<TagStatistics>(results.size()); for (Object result : results) { Object[] data = (Object[]) result; Tag tag = (Tag) data[0]; int articleCount = ((Number) data[1]).intValue(); tagStats.add(new TagStatistics(tag, articleCount)); } return tagStats; } public int queryMostArticles() { return ((Number) getSession() .getNamedQuery("Tag.MostArticles") .uniqueResult()) .intValue(); } }
The
getAllTagStatisticsInRange
call does most of the work; it calls the appropriate Hibernate query and then wraps the resulting tuple in a list of
TagStatistics
objects.
We now have all the information necessary to build our tag cloud: the tags themselves, the number of articles per tag, and the maximum number of articles that any one tag has. We could call the DAO methods directly from our presentation layer, but there is still some boilerplate code we would need to implement the full tag cloud. Since we will probably want to show this on several different pages, let's take things a bit further:
public interface TagBusiness { public List<TagCloudEntry> getTagCloud(); }
public class TagBusinessImpl implements TagBusiness { private TagDao tagDao; // ... public List<TagCloudEntry> getTagCloud() { List<TagStatistics> tagStats = tagDao.queryAllTagStatistics(); int mostArticles = tagDao.queryMostArticles(); List<TagCloudEntry> cloud = new ArrayList<TagCloudEntry>(tagStats.size()); for (TagStatistics tag : tagStats) { if (tag.getArticleCount() > 0) cloud.add(new TagCloudEntry(tag, mostArticles)); } return cloud; } }
public class TagCloudEntry extends TagStatistics { private int scale; public TagCloudEntry() { super(); } public TagCloudEntry( TagStatistics stat, int maximumArticleCount) { super(stat.getTag(), stat.getArticleCount()); if (maximumArticleCount <= 0) scale = 0; else setScale((getArticleCount() * 10) / maximumArticleCount); } public int getScale() { return scale; } public void setScale(int scale) { if (scale < 0) scale = 0; if (scale > 9) scale = 9; this.scale = scale; } }
The
getTagCloud()
method and the new
TagStatistics
subclass,
TagCloudEntry
provide us with a simple list of tag cloud entries. Each entry allows us access to the underlying
Tag
object, but also a new property,
scale
. This is an integer from 0 to 9 inclusive which gives the relative popularity of this particular tag.
In our presentation layer, we simply call
TagBusiness.getTagCloud()
, place the result in the request attributes, and render our tag:
<!-- tagcloud.jsp --> <%@taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> <%@taglib uri="http://randomcoder.com/tags-escape" prefix="rcesc" %> <div class="sectionHeading">Tags</div> <div class="sectionContent" align="right"> <div class="tagCloud"> <c:forEach var="tagCloudEntry" items="${tagCloud}" varStatus="status"> <c:url var="tagLink" value="/tags/${rcesc:urlencode(tagCloudEntry.tag.name)}" /> <c:url var="tagClass" value="cloud${tagCloudEntry.scale}" /> <c:if test="${status.index > 0}">::</c:if> <a rel="tag" class="tag ${tagClass}" href="${tagLink}"> <c:out value="${tagCloudEntry.tag.displayName}" /> </a> </c:forEach> </div> </div>
This gives us links with class names like
cloud0
,
cloud1
, ...
cloud9
. Adding a little CSS provides us with some visual distinction:
.cloud0 { font-size: 1.00em; } .cloud1 { font-size: 1.10em; } .cloud2 { font-size: 1.20em; } .cloud3 { font-size: 1.30em; } .cloud4 { font-size: 1.40em; } .cloud5 { font-size: 1.50em; } .cloud6 { font-size: 1.60em; } .cloud7 { font-size: 1.70em; } .cloud8 { font-size: 1.80em; } .cloud9 { font-size: 1.90em; }
Using
em
for our unit of measurement allows the links to be sized relative to the current font size in the document.
And there you have it, a simple tag cloud. Since this code is in use on this web site, you can download all of it from the randomCoder subversion repository .