Over a million developers have joined DZone.

Javascript Tutorial - Radial Menus Part 1

· Web Dev Zone

Start coding today to experience the powerful engine that drives data application’s development, brought to you in partnership with Qlik.

[img_assist|nid=4093|title=|desc=|link=none|align=left|width=50|height=50]Sometimes, user interfaces can be really constraining. A whole lot of stuff to do, and not nearly enough pixels to do it in. In regular desktop applications, in is extremely common to put useful and repetitive items in a context menu, to keep them from cluttering up the screen. However, it is generally frowned upon to put context menus on a web page - because then they will interfere with the browser's normal context menu. But, as always, there are special cases - for instance, Google Maps has its own context menu, and it seems to work pretty well.

So today, we are going to talk about context menus in web pages. But not any old context menus - we are going to talk about radial menus, or more specifically, how to make your own radial menus. What are radial menus, you ask? Also known as pie menus, they are essentially a circular popup/context menu. They are not particularly common in standard windows apps (because they are not in any of the standard widget libraries), but they do often make appearances in video games.

If you are using Firefox, and you want to play around with using a radial/pie menu, you can download the easyGestures extension that gives you a radial menu as a replacement for the standard context menu. But enough about desktop apps - we care about web pages! The site that initially gave me the idea to mock this up is Songza. They use radial menus to present all the options for a particular song, as you can see in the screenshot below:

[img_assist|nid=4094|title=|desc=|link=none|align=none|width=297|height=168]

The example we will be making today is not nearly as pretty, but I hope to have a much improved (and easily extensible) version for a Part 2 sometime in the not too distant future. So go ahead, left-click in the box below to bring up the menu (you can even grab the middle of the menu to drag it around): See the working example here. Time to dive into the code. First we are going to look at the html, which is really very standard:

<div id="radialContainer" style="position:absolute;
display:none;">
<div id="radialTop" class="radialLevel1" style="top:-90px;
left:-30px;border-bottom-width:0px;cursor:pointer;"
onclick="closeMenu();alert('Delete!');">
<!-- Content For Top Menu -->
</div>
<div id="radialLeft" class="radialLevel1"
style="top:-30px;left:-90px;border-right-width:0px;">
<!-- Content For Left Menu -->
<div id="radialLeftSubContent" class="radialLevel2"
style="left:-100px;top:-50px;display:none;
width:100px;height:160px;">
<!-- Content For Left SubMenu -->
</div>
</div>
<div id="radialBottom" class="radialLevel1"
style="top:30px;left:-30px;border-top-width:0px;">
<!-- Content For Bottom Menu -->
<div id="radialBottomSubContent" class="radialLevel2"
style="left:-50px;top:60px;display:none;
width:160px;height:100px">
<!-- Content For Bottom SubMenu -->
</div>
</div>
<div id="radialRight" class="radialLevel1" style="top:-30px;
left:30px;border-left-width:0px;cursor:pointer;"
onclick="closeMenu();alert('Print!');">
<!-- Content For Right Menu -->
</div>

<div id="radialCenter" class="radialSquare" style="top:-30px;
left:-30px;cursor:move;background-color:White;">
</div>
</div>

So first we have a container div, called here radialContainer. Obviously, most of the time, this div will be hidden (because the menu won't be displayed), so the style display:none is set. And it needs to be absolutely positioned, because we will be opening it according to the absolute mouse position.

Inside this container div, there are 5 more divs. One for each of the four leaves (radialTop, radialLeft, radialBottom, radialRight), and the center square (radialSquare). Everything is positioned such that the center of the menu is at the (0,0) position relative to the container. That way we can just set the top and left position of the container to the current mouse position, and the menu will be positioned appropriately.

To understand exactly what the styles are doing, it is probably helpful to see the classes 

.radialSquare, .radialLevel1
{
position:absolute;
height:60px;
width:60px;
border:solid 1px #B4B4B4;
}

.radialLevel1
{
background-color:#E2E2E2;
border:solid 1px #B4B4B4;
}

.radialLevel2
{
position:absolute;
background-color:#E2E2E2;
border:solid 1px #B4B4B4;
}

You might noticed some funkiness with the border - how I'm clearing the inner border of every one of the level 1 squares. This is because of the difference between how Firefox and IE render borders - Firefox puts them inside the element, IE puts them outside. By clearing the inner border of each of the level 1 squares, and giving the inner square a border, we can make it so that the menu looks consistent across browsers (if not to the pixel identical). Doesn't everyone love standards?

So, back to that html. Inside each of the leaf divs, there is a comment like <!-- Content For Top Menu -->. This is because, well, that is where the content goes. You can put pretty much whatever you want in there, and in the case of the example menu here, I threw in an image and some text. There is one special case about stuff inside that div, though - and you can see it if you look at the left or the bottom div.

If you want a square to have a submenu, you add a div with the id of the square plus the string "SubMenu". For example, the radialLeft square has a submenu with the id radialLeftSubMenu. We will see in a bit that the javascript code actually looks for divs with ids in this format to determine if a menu has a submenu. And just like with the level 1 menus, you can put whatever you want inside the level 2 menu.

Ok, on to the javascript code. First, let's take a look at how we trigger the menu to come up:

var g_RadialDragObj = null;
var g_RMenu = null;
var g_LeafArray = null;
var g_LeafSubArray = null;


function triggerMenu(e)
{
if(g_RMenu == null)
{
g_RMenu = document.getElementById('radialContainer');

g_LeafArray = new Array();
g_LeafArray.push(
document.getElementById('radialTop'));
g_LeafArray.push(
document.getElementById('radialLeft'));
g_LeafArray.push(
document.getElementById('radialBottom'));
g_LeafArray.push(
document.getElementById('radialRight'));

g_LeafSubArray = new Array();
g_LeafSubArray.push(
document.getElementById('radialTopSubContent'));
g_LeafSubArray.push(
document.getElementById('radialLeftSubContent'));
g_LeafSubArray.push(
document.getElementById('radialBottomSubContent'));
g_LeafSubArray.push(
document.getElementById('radialRightSubContent'));

g_RadialDragObj =
new dragObject('radialContainer', 'radialCenter');
}

e = e ? e : window.event;
if(g_RMenu.style.display == '' || e.button != 0)
return;

var pos = absoluteCursorPostion(e);

g_RMenu.style.left = pos.X + 'px';
g_RMenu.style.top = pos.Y + 'px';
g_RMenu.style.display = '';

hookEvent(document, "mousedown", testCloseMenu);
hookEvent(document, "mousemove", subMenuActivate);
}

At the top there, we have a couple global variables. They essentially are going to be holding references to the various html elements that make up the radial menu, so we don't have to continuously look them up. Hopefully in version two of this this, I will encapsulate everything so we don't need any global variables.

The triggerMenu function should be attached to the onclick event of the element that you want to raise the menu. So in the case of the example on this page, the code looks something like this:

<div style="border:solid 1px black;width:500px;height:200px;" onclick="triggerMenu(event);">
Click In Me!!
</div>

So we enter the triggerMenu function. First, we want to set those global variables if they are not already set. So we do a bunch of document.getElementById calls. As you can tell, the g_LeafSubArray will have null entries for leaves that have no submenu - and this works out well for us, as we will see in a bit. But what's this at the end of the block of code for setting the global variables? What does new dragObject mean?

Well, we are using code here from the javascript tutorial on Draggable Elements (man, we always seem to be using that code). What that line does is enable the user to grab the center of the menu (radialCenter) and drag the whole thing around (radialContainer). For more info on how that works, you should check out that tutorial

Next, we pull out the event object for the click, and do a simple check - if the menu is already visible, or if the click was not with the left button, we bail out of the function (when button is equal to 0, it means the left mouse button was clicked).

Next, we grab out the absolute position of the cursor. This function is also from the Draggable Elements tutorial, but it is simple enough to reproduce here:

function absoluteCursorPostion(eventObj)
{
eventObj = eventObj ? eventObj : window.event;

if(isNaN(window.scrollX))
return new Position(eventObj.clientX
+ document.documentElement.scrollLeft
+ document.body.scrollLeft,
eventObj.clientY
+ document.documentElement.scrollTop
+ document.body.scrollTop);
else
return new Position(
eventObj.clientX + window.scrollX,
eventObj.clientY + window.scrollY);
}

For the explanation of why we have to do so much work just to get the absolute mouse position, you should go check out that tutorial. The object that this function returns is a Position object (also defined in the Draggable Elements code), but it does exactly what you might expect - hold an X and Y position.

So now that we have the absolute mouse position, we set the top and left of the radial menu container to this position, and make the menu visible. Finally, we hook into the mousedown and mousemove events on the document, using the hookEvent function. The hookevent function is a nice wrapper around the different browser code for hooking an event - you can learn about it in the Working With Events tutorial.

Why do we hook into the mousedown and mousemove events, and why do we have to hook them at the document level? That's a good question, so let's first take a look at the mousedown case.

function testCloseMenu(e)
{
var tar = getEventTarget(e);

do
{
if(tar == g_RMenu)
return;
tar = tar.parentNode;
}while(tar != null);

closeMenu();
}

function getEventTarget(e)
{
e = e ? e : window.event;
return e.target ? e.target : e.srcElement;
}

The function testCloseMenu gets called on every mouse down event on the document once the radial menu is up. This is because we want to close the menu as soon as the user clicks something that is not actually inside of the menu. So on every mouse down, we get the event target, and we walk up the tree of elements from parent to parent, checking to see if the element that was clicked is a child of the radial menu. If it is, we leave the menu open, but if it isn't, we call the function closeMenu.

function closeMenu()
{
unhookEvent(document, "mousedown", testCloseMenu);
unhookEvent(document, "mousemove", subMenuActivate);

g_RMenu.style.display = 'none';

for(var i=0; i <g_LeafArray.length; i++)
{
g_LeafArray[i].style.backgroundColor = '#E2E2E2';
if(g_LeafSubArray[i] != null)
g_LeafSubArray[i].style.display = 'none';
}
}

So the first thing we do when we close the radial menu is unhook those two events. This unhookEvent function is from the same place as the hookEvent function - the tutorial "Working With Events". We then hide the menu by setting the display to none on the menu container. And then we walk through all the elements of the menu, setting everything back to their initial state (in case the menu was closed while an element was still highlighted, or a sub menu was visible).

Now, lets take a look at why we hooked that mousemove event:

function subMenuActivate(e)
{
var tar = getEventTarget(e);

do
{
var found = false;
for(var i=0; i <g_LeafArray.length; i++)
if(tar == g_LeafArray[i])
{
found = true;
break;
}
if(found)
break;
tar = tar.parentNode;
}while(tar != null);

for(var i=0; i <g_LeafArray.length; i++)
{
if(g_LeafArray[i] == tar)
{
g_LeafArray[i].style.backgroundColor = '#BEBEBE';
if(g_LeafSubArray[i] != null)
g_LeafSubArray[i].style.display = '';
}
else
{
g_LeafArray[i].style.backgroundColor = '#E2E2E2';
if(g_LeafSubArray[i] != null)
g_LeafSubArray[i].style.display = 'none';
}
}
}

When the user's mouse is over a menu item, we want it to highlight and the submenu (if there is any) to appear - and when the mouse leaves for everything to go back to normal. And since mouseout is a very finicky event (it won't fire if you move your mouse too fast) it is a whole lot easier to track the mouse across the entire document. We do the same type of parent tree walking here as we did in the testCloseMenu function, except in this case we are looking to match any of the four leaves. If we match one, that leaf should highlight and the submenu appear.

So as soon as we find a match, we break out of the loop, and proceed to loop though the leaves. If the leaf matched, we change the background color to the highlight color, and if there is a submenu we make it visible. If the leaf did not match, we make sure that the background color is the standard color and that the submenu is hidden.

And there we go! Granted, the code is not as clean as I would like, but this was my first stab at making a radial menu for a web page. Hopfully, I'll get around to refactoring this and making it a lot more extensible soon, so look forward to a part 2. You can grab all the code for this example here. As always, if you have any questions or comments, feel free to leave them below.

Create data driven applications in Qlik’s free and easy to use coding environment, brought to you in partnership with Qlik.

Topics:

Published at DZone with permission of Charlie Key. See the original article here.

Opinions expressed by DZone contributors are their own.

The best of DZone straight to your inbox.

SEE AN EXAMPLE
Please provide a valid email address.

Thanks for subscribing!

Awesome! Check your inbox to verify your email so you can start receiving the latest in tech news and resources.
Subscribe

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}