ORIGINAL DRAFT

The ability to associate actions with image segments is a powerful way of enabling users to select from a group of choices. The standard server-side and client-side image maps available to HTML programmers allow web developers to associate links with a given part of an image. This month’s visual component is a variant on the same basic theme that lets you work with regions associated with Action objects. By using Action objects, we make it possible to develop sophisticated multimedia-style user interfaces that let the user navigate more visually.

Our implementation supports a simple import capability that lets you use both server-side and client-side image map definitions, enabling you to use standard web tools to develop you image maps. We’ll use an internal representation that’s defined by a model called an ImageMap, with a default implementation that behaves accordingly. This loose coupling allows you to implement your own models to suit various needs.

Figure 1: JImageMapTest.

Figure 1: JImageMapTest.

Figure 1 shows a screen shot of the output of the JImageMapTest class, which takes three images, a client or server map file and a hand-constructed default image map, allowing the user to click anywhere on the image. If associated Actions have toString method, we display that string as a JToolTip. Notice that both the tool tip and highlighted shape are present because the mouse was just above the tool tip position when the screen shot was taken. To provide the user with useful visual cues, the shape is drawn using a clipped region from another image.

Figure 2.1: Example striped image.

Figure 2.1: Example striped image.

Figure 2.2: Example twists image.

Figure 2.2: Example twists image.

Figure 2.3: Example solids image.

Figure 2.3: Example solids image.

The three images I used in this example were generated by rendering the shapes with different textures. The first image is the image you typically look at. The second is used to overlay the selected shape when the mouse hovers over the region. When you use the program, you’ll notice that the rest of the image stays the same. Only the shape regions change when the mouse is moved over them. The third image is used to replace shapes when the mouse is clicked.

Figure 3: JImageMap classes.

Figure 3: JImageMap classes.

The classes that make up JImageMap are shows in Figure 3. The ImageMap interface is implemented by the DefaultImageMap. AbstracMapFile extends the DefaultImageMap and expose the ImageMap interface by default. This makes it easy to handle client and server-specified image maps. This way, you can chose several methods to specify image maps.

The most powerful ImageMap class is the DefaultImageMap. You can use any Java Shape object to define a hot spot and associated it with an Action using the setShapeAction method. Standard image maps are restricted to circles, rectangles and polygons, while the Shape classes are considerably more flexible. You can use the setDefaultAction method to deal with cases where the user clicks outside the shape regions.

The ImageMap interface looks like this:

public interface ImageMap
{
  public void setDefaultAction(Action action);
  public void setShapeAction(Shape shape, Action action);
  public void removeShape(Shape shape);
  public Action getAction(int x, int y);
  public Shape getShape(int x, int y);
  public Shape[] getShapeList();
}

The AbstractMapFile is an abstract base class for file-based image maps. It handles reading and parsing files. There are two abstract methods - getDelimiters and parseShapes - which handle the actual parsing in subclasses, as well as a method you may choose to implement in your own subclasses called getActionFor.

The getActionFor method is a mechanism that lets you associate actions based on the URL string in the standard image maps. Naturally, these strings can be anything you like, though in web applications they are always URLs. By default, we use a simple PrintLineAction, which merely prints the string it was given. This is useful for testing but does little in the way of practical solutions.

The ClientMapFile and ServerMapFile implementations extend AbstractMapFile and implement specific parsing associated with the different image map formats. These parsers are rudimentary at best and are present primarily to illustrate what can be done with the interface. The error handling and syntax error feedback are virtually non-existent.

The example Client.map and Server.map files illustrate the syntax we support. The design of these classes is simple, intended to allow you to easily cut and paste the output from a typical image map editing tool into a separate file. You can subclass either the ClientMapFile or ServerMapFile classes and implement your own getActionFor method to associate the URL string with any action you want to use.

Let’s take a closer look at the DefaultImageMap and the JImageMap classes. You can find the rest of the code online at www.java-pro.com, along with the example images and map files. The JImageMapTest program assumes these are in a subdirectory called images. If you move them elsewhere, make sure you edit the JImageMapTest code.

Listing 1 shows the source code for DefaultImageMap. We use a hash table to store associations between Shape and Action instances. The default Action is stored in an instance variable., which can be set via the setDefaultAction method. The setShapeAction method merely puts a Shape key and Action value into the hash table. The removeShape method removes a Shape key from the table.

The getAction and getShape methods are a little more interesting. They both find the first Shape that contains the x and y coordinates by iterating through each shape in the hash table and using the contains method in the Shape interface to select the first match. The getShape method returns the shape, while the getAction method returns the Action for the shape at the specified location.

You’ll notice that the getAction and getShape methods return the first match. Since we’re using a hash table, the order in which the keys are listed is not preserved. This design assumes you’ll never have a problem if you don’t use overlapping regions. If get unexpected results, keep this in mind when you’re doing diagnosis.

The last method in DefaultImageMap is getShapeList, which merely iterates through the hash table and returns an array of Shape objects.

Listing 2 shows the code for JImageMap. We provide multiple constructors to support different image combinations. Internally, we assume three images can be used - one for the main image, one for the highlights, triggered when the mouse is over the shape, and one for mouse presses. These are stored as image, light and press instance variables, respectively.

The main constructor adds mouse and mouse motion listeners and registers the component with the ToolTipManager so we can present tooltips to the user. We implement getPreferredSize and getMinimumSize methods that both return the image size, accounting for any border.

The paintComponent method does all the drawing. The background is erased and the main image is drawn at the inset location. Because we use the Shape interface, we cast a Graphic2D object and we set the antialiasing property to keep the edges as smooth as possible.

There are only a few possible states we can find ourselves in. If the mouse is pressed, we set the clipping region to the containing shape and draw that segment of the press image if it exists. If the state is selectable, meaning the mouse is over the shape but not pressed, we draw the light image segment if the light image is not null. If there is no light image, we draw the outline of the shape to provide feedback for the user.

The mousePressed event also triggers the Action associated with the shape being selected by calling its actionPerfomed method. The other mouse events are there primarily to change the state and repaint the images.

In order to support tooltips we implement a getToolTipLocation method that returns null if you’re not over an existing shape, or a point slightly offset so as not to cover the mouse cursor. The getToolTipText method actually returns the text, which is retrieved by applying the toString method to the Action associated with the current mouse location.

You can get a good sense of how to apply the different solutions by looking at the JImageMapTest code. The DefaultImageMap, ClientMapFile and ServerMapFile are used to implement the same image map choices in three different ways, each presented on a separate JTabbedPane tab. The file content is also displayed in the two cases where that applies.

The JImageMap implementation goes well beyond standard browser image map support, all the while remaining compatible with open standards and allowing you to use existing editing tools to create image maps. With JImageMap, you can use any Shape to trigger an Action command, giving you a powerful new tool for your user interface arsenal.

Listing 1

import java.awt.*;
import java.util.*;
import javax.swing.*;

public class DefaultImageMap
  implements ImageMap
{
  protected Action defaultAction;
  protected HashMap map = new HashMap();

  public void setDefaultAction(Action action)
  {
    defaultAction = action;
  }
  
  public void setShapeAction(Shape shape, Action action)
  {
    map.put(shape, action);
  }
  
  public void removeShape(Shape shape)
  {
    map.remove(shape);
  }
  
  public Action getAction(int x, int y)
  {
    Iterator iterator = map.keySet().iterator();
    while(iterator.hasNext())
    {
      Shape shape = (Shape)iterator.next();
      if (shape.contains(x, y))
        return (Action)map.get(shape);
    }
    return defaultAction;
  }

  public Shape getShape(int x, int y)
  {
    Iterator iterator = map.keySet().iterator();
    while(iterator.hasNext())
    {
      Shape shape = (Shape)iterator.next();
      if (shape.contains(x, y))
        return shape;
    }
    return null;
  }
  
  public Shape[] getShapeList()
  {
    Shape[] list = new Shape[map.size()];
    Iterator iterator = map.keySet().iterator();
    int index = 0;
    while(iterator.hasNext())
    {
      list[index] = (Shape)iterator.next();
      index++;
    }
    return list;
  }
}

Listing 2

import java.awt.*;
import java.awt.image.*;
import java.awt.event.*;
import javax.swing.*;

public class JImageMap extends JPanel
  implements MouseListener, MouseMotionListener
{
  protected Image image, light, press;
  protected ImageMap model;
  protected Shape selectable, pressed;

  public JImageMap(
    Image image, ImageMap model)
  {
    this(image, null, null, model);
  }
  
  public JImageMap(
    Image image, Image light, ImageMap model)
  {
    this(image, light, null, model);
  }
  
  public JImageMap(
    Image image, Image light, Image press,
    ImageMap model)
  {
    this.image = image;
    this.light = light;
    this.press = press;
    this.model = model;
    addMouseListener(this);
    addMouseMotionListener(this);
    ToolTipManager.sharedInstance().registerComponent(this);
  }

  public Dimension getPreferredSize()
  {
    Insets insets = getInsets();
    int w = insets.left + insets.right;
    int h = insets.top + insets.bottom;
    return new Dimension(
      image.getWidth(null) + w,
      image.getHeight(null) + h);
  }
  
  public Dimension getMinimumSize()
  {
    return getPreferredSize();
  }
  
  public void paintComponent(Graphics gc)
  {
    Insets insets = getInsets();
    int x = insets.left;
    int y = insets.top;
    int w = getSize().width;
    int h = getSize().height;
    Graphics2D g = (Graphics2D)gc;
    g.setRenderingHint(
      RenderingHints.KEY_ANTIALIASING ,
      RenderingHints.VALUE_ANTIALIAS_ON);
    g.setColor(getBackground());
    g.fillRect(0, 0, w, h);
    g.drawImage(image, x, y, this);
    if (pressed != null)
    {
      if (press != null)
      {
        g.setClip(pressed);
        g.drawImage(press, x, y, this);
      }
    }
    if (selectable != null)
    {
      if (light != null)
      {
        g.setClip(selectable);
        g.drawImage(light, x, y, this);
      }
      else
      {
        g.setColor(getForeground());
        g.draw(selectable);
      }
    }
  }
  
  public Point getToolTipLocation(MouseEvent event)
  {
    Shape shape = model.getShape(event.getX(), event.getY());
    if (shape == null) return null;
    return new Point(
      event.getPoint().x,
      event.getPoint().y + 20);
  }
  
  public String getToolTipText(MouseEvent event)
  {
    Action action = model.getAction(event.getX(), event.getY());
    return (action != null) ? action.toString() : null;
  }
  
  public void mouseEntered(MouseEvent event) {}
  public void mouseExited(MouseEvent event) {}
  public void mouseClicked(MouseEvent event) {}
  public void mouseDragged(MouseEvent event) {}
  
  public void mousePressed(MouseEvent event)
  {
    Action action = model.getAction(event.getX(), event.getY());
    if (action == null) return;
    ActionEvent actionEvent = new ActionEvent(
      this, ActionEvent.ACTION_PERFORMED, action.toString());
    action.actionPerformed(actionEvent);
    pressed = model.getShape(event.getX(), event.getY());
    selectable = null;
    repaint();
  }
  
  public void mouseReleased(MouseEvent event)
  {
    selectable = model.getShape(event.getX(), event.getY());
    pressed = null;
    repaint();
  }
  
  public void mouseMoved(MouseEvent event)
  {
    selectable = model.getShape(event.getX(), event.getY());
    pressed = null;
    repaint();
  }
}