Showing posts with label javascript. Show all posts
Showing posts with label javascript. Show all posts

2008-11-09

Creating dynamic client-side image maps with javascript/jQuery

I recently ran into an odd problem - I needed a dynamic client-side image map. Dynamic as in "the areas will be in the right spot regardless of how large the image is client-side".

I've been working on a site for the house I live in, which creates a lot of various forms of media. We decided to offload video hosting for videos we make to youtube, since we can't afford hosting costs for streaming video at this point in time. Youtube offers a pretty neat chromeless player that you can control through javascript, and the guy who does most of our design work whipped up a pretty nice-looking television with buttons image to use as the player. Given that he's not exactly very tech-savvy, it seemed like using an image map (which I haven't really heard much about for years) would be an appropriate way to make the player

Now, the problem with image maps is that since the coords attribute of the area tags is based off of pixels, you can't easily use an image that you don't know what dimensions it will end up being on the clients browser.

This is one of the situations I've run into where the solution probably would have been obvious if I had gone to college, but I enjoy solving problems anyhow - I got out the graph paper and pencil, drew a few boxes with points in them, and it became apparent that if I represented the coords for the areas as ((x/image_width)/(y/image_height)) instead of just (x/y), that it would be pretty easy to modify for any image size.

Javascript to the rescue:


//the height and width of the image on the server
CircleButton.prototype.base_width = 800;
CircleButton.prototype.base_height = 600;

//stores information about where buttons are as the ratio of
//(x/base_width)/(y/base_height)
function CircleButton(x, y, diameter, func) {
//express x,y,diameter as ratios instead of pixels.
this.x = x / this.base_width;
this.y = y / this.base_height;
this.diameter = diameter * ((this.base_width / this.base_height)
/ (this.x / this.y));
this.func = func;

this.adjust_coords = function(width, height) {
//coordinates for an image of (width, height) on the client
return [parseInt(width * this.x),
parseInt(height * this.y),
parseInt(this.diameter)];

}

this.tag_string = function(width, height) {
var coords = this.adjust_coords(width, height);
return '<area shape="circle" coords="' + coords.join() + '" href="javascript:' + this.func + '"/>';
}
}


This only works for circular buttons, but would be easy to modify for any of the other shape options (I only need circular buttons at the moment).

To use this on a page, you need to

  1. Initialize some buttons with the coords that they would have in the image on the server
  2. Find the image's width and height on the client
  3. Generate the area tags with the previously found width and height
Like so:

function setup_map() {
var base_buttons = [new CircleButton(525, 127, 15, 'ytplayer.playVideo();'),
new CircleButton(584, 153, 15, 'ytplayer.pauseVideo();')];

//size of the map image on the client
var map_width = $('#my-image').width();
var map_height = $('#my-image').height();

$('#my-map').empty();
$('#my-image').removeAttr('usemap');

var the_map = document.getElementById('my-map');

for (var i = 0; i < base_buttons.length; i++) {
(function(b) {
s = base_buttons[b].tag_string(map_width, map_height);
//we have to append to the innerHTML like this because of the way
//jQuery appends stuff
the_map.innerHTML += s;
})(i);
}

$('#my-image').attr('usemap','#my-map');
}


Assuming markup like this:


<div id="video" class="content">

<map id="my-map" name="my-map">
</map>

<img id="my-image" src="images/my-image.png"/>
<object width="48%" height="52%" id="video-player"
allowscriptaccess="always"
data="http://youtube.com/apiplayer?enablejsapi=1&playerapiid=ytplayer">
</object>
</div>


The calls to empty and removeAttr can probably be removed if, unlike me, your image and map are not residing in a jQuery tab. We have to append to the innerHTML of the map because of a quirk with how jQuery does appends - as far as I can tell, it appends to an empty div - and area tags aren't valid inside a div, so the browser ends up not displaying them. That was a bit frustrating to figure out.

Also, if you're like me, and the image and map are residing in a jQuery tab, you'll want to bind setup_map to the click attribute of the tab with a timeout like so:


$("a[href='#my-tab']").click(function() {
//we have to wait a little bit for the player
//image to load, otherwise it reports a width of 0
setTimeout('setup_map()', 100);
});


Just about any amount of delay should work, 100 milliseconds is unnoticeable, 500 would work if you wanted to be really safe

Image maps don't get much action these days, probably because when they were made, there wasn't much of a use for them - and now, there are other ways to accomplish what they were generally used for. I do feel that they were quite appropriate for making a custom player with youtubes chromeless player, and it was fairly straightforward to allow for differing image sizes on the client side. Hooray!

2008-04-29

So maybe it wasn't so simple after all.

Well, as I said previously, I did come up with a way to accomplish what I was trying to accomplish.

Basically, what I wanted was to be able to say: "This group of options under this tag are a logical set, and only one of them may be considered active at any given point in time"

This is what I converged upon:



function UniqueSelection(id, extras, allowToggle, childTag) {
if(!childTag)
childTag = 'li';

var parent = document.getElementById(id);
var items = parent.getElementsByTagName(childTag);
var currentItem = false;

function choose(choice) {
if(!currentItem) {
currentItem = items[choice];
currentItem.className = 'active';
}else{
if(currentItem != items[choice]) {
currentItem.className = 'inactive';
currentItem = items[choice];
}

if(allowToggle && currentItem.className == 'active') {
currentItem.className = 'inactive';
}else {
currentItem.className = 'active';
}

}

}

for(var i = 0; i < items.length; i++) {
items[i].onclick = (function(id, func) {
if (!func) {
func = function() {
return true;
};
}

return function() {
choose(id);
func();
};
})(items[i].id,
(extras ? extras[items[i].id] : false));
if(items[i].className == 'active') {
currentItem = items[i];
}
}


}


This version does exactly what I need it to, although the creation syntax is a little funky if you're not used to passing objects as parameters:


//to create just a list, no extra actions
justAList = new UniqueSelection('simple-list');

//to create a list where three elements have extra actions:
normalUse = new UniqueSelection('normal-list',
{ 'first-element': firstElementsFunc,
'second-element': secondElementsFunc,
'third-element' : thirdElementsFunc});


This version also doesn't loop through the child elements on every click - it just works with the current element, which means that the choice function shouldn't slow down on large lists.

I'm not sure if this is the javascripty way of accomplishing this, but it works for me. It's not exactly efficient as far as memory usage goes, but it could be a lot worse (and it works). I'm still learning javascript, and it hasn't quite seeped entirely into my brain yet, but it's getting closer.

2008-04-28

A simple way to manage tab-lists, and other unique selections.

So, in the current project I'm working on, I've run into a few situations where I want to have a list of options, only one of which is allowed to be "active" at any given point in time. Examples: tabbed interfaces, editing "modes" for the shape and grid editors that I'm working on (you can be in "deleting" mode or "inserting" mode or "setting center" mode, for instance).

Basically, all I needed was to be able to say "This list of stuff is only allowed to have one thing selected at once", while showing the active selection with css.

As a first attempt, I wrote the following little bit of javascript:

function UniqueSelection(id) {
function toggle() {
UniqueSelection.active.className = "inactive";
this.className = "active";
UniqueSelection.active = this;
}

var listItems = document.getElementById(id).getElementsByTagName('li');

for(var i = 0; i < listItems.length; i++) {
listItems[i].onclick = toggle;
if(listItems[i].className == 'active'){
UniqueSelection.active = listItems[i];
}
}
}


Which works, but has one big flaw: you can only have one UniqueSelection at a time, and is a pretty ugly solution anyhow.

The following code allows you to have as many UniqueSelection elements as you want, and is shorter, clearer code:


function UniqueSelection(id) {
var listItems = document.getElementById(id).getElementsByTagName('li');

function toggle() {
for(var i = 0; i < listItems.length; i++) {
listItems[i].className = 'inactive';
}
this.className = 'active';
}

for(var i = 0; i < listItems.length; i++) {
listItems[i].onclick = toggle;
}
}


This does have some downsides - for one, it might have a visible delay on long lists. For two, you can't have multiple class names on your 'li' elements. For my purposes, this is fine - and I think that if you're using ul's in a semantic manner, it shouldn't be a problem.

The code above does show some interesting things about javascripts scoping rules though.



Update:

With a couple more lines of code, we can allow for both lists where a selection must be unique and a selection must be active, and lists where a selection must be unique, but where the current selection can be turned off without making another one:


function UniqueSelection(id, allowToggle) {
var listItems = document.getElementById(id).getElementsByTagName('li');

function choose() {
for(var i = 0; i < listItems.length; i++) {
if(listItems[i] != this)
listItems[i].className = 'inactive';
}

if(allowToggle && this.className == 'active') {
this.className = 'inactive';
}else{
this.className = 'active';
}

}

for(var i = 0; i < listItems.length; i++) {
listItems[i].onclick = choose;
}
}




Update 2: The above function clobbers the onclick attribute, and in some cases I need it to not do so. Getting it to not clobber is turning out to be frustrating though. . .


Update 3: Got it working, through a bit of education and head-banging. The new code and an explanation of what was going on deserves it's own post though. Will write it up later.