ORIGINAL DRAFT

This month’s component allows us to present multiple bounded ranges in a single, integrated view. By providing a simple mechanism that communicates the relationships between ranges, JRadar allows the user to make balanced adjustments to these values. While JRadar is not a common type of user interface element, it provides a unique blend of visual cues which users may find more intuitive that a set of JSlider controls. This kind of view is especially useful in system management sofware, where changes to the contour can quickly be detected, even in a cluttered display. In the right context, JRadar is a powerful way to communicate with an application user.

Figure 1 shows a screen shot of the JRadar component, with values associated with different types of resources. There are several reusable classes in this component, including a CircleLayout manager. We’ll use the Swing BoundedRangeModel to define each of the adjustable values so that you can attach listeners to them if you like. The RadarModel contains a String label for each BoundedRangeModel, along the spokes of our widget. We’ll keep the view and the editing functionality loosely coupled so that you can use JRadar in different ways, depending on your needs.

Figure 1: JRadar in action.

Figure 1: JRadar in action.

Our implementation necessarily starts with a suitable model. We’re primarily concerned with managing a set of BoundedRangeModel instances, each associated with a given String label. We need to support a getCount method to find out how many entries there are. We also want to be able to add or remove ranges, get a range or a label by it’s index value, and find the index value for a given label or bounded range. We expect to translate frequently between a bounded range representation and an amplitude value, so we’ll add a couple of methods to make it easier to manage the view and editing functionality. An amplitude value is a decimal number between zero and one.

public interface RadarModel
{
  public int getCount();
  public void addRange(String label, BoundedRangeModel range);
  public void removeRange(String label, BoundedRangeModel range);
  public BoundedRangeModel getRange(int index);
  public String getLabel(int index);
  public int indexOf(String label);
  public int indexOf(BoundedRangeModel model);
  public double getAmplitude(int index);
  public void setAmplitude(int index, double amplitude);
  public void addRadarListener(RadarListener listener);
  public void removeRadarListener(RadarListener listener);
}

Finally, we’ll be using a custom RadarEvent and an associated RadarListener interface to consolidate bounded range events. A RadarEvent extends the standard Java EventObject and encapsulates a source object (the RadarModel), as well as the String label and BoundedRangeModel associated with each event. The RadarListener interface is very straight forward and looks like this:

public interface RadarListener
{
  public void RadarChanged(RadarEvent event);
}

Listing 1 shows the DefaultRadarModel class, which implements the RadarModel interface. We manage three ListArray objects to keep track of labels, bounded range models and listeners. The getCount method returns the size of the models array. The addRange and removeRange add or remove a label and associated bounded range model and register or unregister the model as a ChangeListener so that we can watch for changes in each bounded range model.

The getRange and getLabel methods merely cast the indexed object in the two respective array lists. The two indexOf methods pass on the indexOf behavior directly to the array list. The addRadarListener and removeRadarListener methods simply add or remove a RadarListener to the listeners array list. The fireRadarEvent method provides us with a simple mechanism to fire off a RadarEvent by passing in a label and bounded range reference. We call it when we receive a ChangeEvent from one of the bounded range models to keep any listeners up to date.

The last pair of methods we need to implement are the getAmplitude and setAmplitude methods. The methods do a little bit of simple math to translate to and from relative value within a bounded range. These methods function based on the getMinimum, getMaximum and getValue attributes of the BoundedRangeModel. The setAmplitude method translates from a range between zero and one to the value that represents a relative position in the bounded range.

Figure 2 shows the various classes we need to make JRadar functional. We’ve covered the RadarModel, DefaultRadarModel, RadarEvent and RadarListener classes already. We’ll skip over the AbstractLayout and CircleLayout implementations though I will mention a few things about them. AbstractLayout is the foundation of all the layout managers I implement. If you read the column on a regular basis you’ve seen it before. It merely deals with the most common layout manager requirements in a unified manner and lets you extend the class to implement specific layout manager behaviors.

Figure 2: JRadar classes.

Figure 2: JRadar classes.

CircleLayout arranges a set of components around a circle, centered on the middle of the parent component. It assumes the components are on the outside of the circle, as you’d find on a clock face. We paint components starting right after 12 o’clock, ending in the 12 o’clock position, distributing them evenly around the outside in a clockwise order. This class is fairly simple and you can find the code online at www.java-pro.com, along with the rest of the classes in this project.

Listing 2 shows the code for the RadarView class. The constructor keeps a reference to the parent JRadar component, along with the number of divisions. We implement a number of utility methods to make life easier. The drawRuler method draws a tick-marked line, translated to the desired angle using the Graphics2D setTransform method. The createOutlineShape creates a GeneralPath polygon that goes around the outside of the component. You’ll find a drawBevel and supporting getPoint method which draw this shape with highlighted and shaded lines to simulate a three dimensional bevel around the outside.

The createAmplitudeShape methods, one of which does the real work while the other abstracts out the arguments for the current component, create a GeneralPath polygon based on the current amplitude values in the model. The paintComponent method draws all this together and uses a pair of foreground and background image buffers to reduce the need to redraw the background unnecessarily. By drawing the foreground layer on top of the background, we can keep calculations to a minimum and achieve the desired effect. We draw the background, then the amplitude shape, and finally the foreground image.

The RadarEdit class extends RadarView and adds editing functionality so that users can change the amplitudes interactively. Listing 3 shows the code for RadarEdit, which implements both the MouseListener and MouseMotionListener interfaces. We draw a small selection rectangle along the closest axis as the mouse is moved around. When the user presses a mouse button, we move the cursor to the closest axis as a convenience. Notice that this takes advantage of the new Robot class, available only under Java 1.3. If you need to use this component under Java 1.2, you can remove this code without ill effect.

The code for JRadar is in Listing 4. It ties the rest of the code together. JRadar assumes you’ll pass in a suitable RadarModel, which you can retrieve using the getModel method. We register as a RadarListener and watch for change events from the RadarModel. This lets us make sure that changes are reflected immediately since we call repaint whenever we see this event. This saves us a lot of overhead and eliminates tight coupling while still satisfying our need to update the display when the model changes. We delegate the addRadarListener and removeRadarListener methods so you don’t need to to have direct access to the model in order to register and unregister listeners.

JRadar is not a particularly complicated component, though it does make use of a few simple tricks with rotated 2D graphics and a little bit of trigonometry. The net effect is a visual component that allows you to communicate concisely with an end user, allowing them to see and manipulate a group of ranges in a balanced context. You can pack a large number of attributes into a small area and empower users to use new tools to communicate with your applications. Use it when the opportunity presents itself.

Listing 1

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

public class DefaultRadarModel
  implements RadarModel, ChangeListener
{
  protected ArrayList models = new ArrayList(); 
  protected ArrayList labels = new ArrayList(); 
  protected ArrayList listeners = new ArrayList();
  
  public int getCount()
  {
    return models.size();
  }
  
  public void addRange(String label, BoundedRangeModel range)
  {
    labels.add(label);
    models.add(range);
    range.addChangeListener(this);
  }
  
  public void removeRange(String label, BoundedRangeModel range)
  {
    range.removeChangeListener(this);
    models.remove(range);
    labels.remove(label);
  }
  
  public BoundedRangeModel getRange(int index)
  {
    return (BoundedRangeModel)models.get(index);
  }
  
  public String getLabel(int index)
  {
    return (String)labels.get(index);
  }
  
  public int indexOf(String label)
  {
    return models.indexOf(label);
  }
  
  public int indexOf(BoundedRangeModel model)
  {
    return models.indexOf(model);
  }
  
  public double getAmplitude(int index)
  {
    BoundedRangeModel model = getRange(index);
    double min = model.getMinimum();
    double max = model.getMaximum();
    double val = model.getValue();
    double amplitude = (val - min) / (max - min);
    return amplitude;
  }

  public void setAmplitude(int index, double amplitude)
  {
    BoundedRangeModel model = getRange(index);
    double min = model.getMinimum();
    double max = model.getMaximum();
    int val = (int)(min + ((max - min) * amplitude));
    model.setValue(val);
  }
  
  public void addRadarListener(RadarListener listener)
  {
    listeners.add(listener);
  }
  
  public void removeRadarListener(RadarListener listener)
  {
    listeners.remove(listener);
  }
  
  public void stateChanged(ChangeEvent event)
  {
    BoundedRangeModel range =
      (BoundedRangeModel)event.getSource();
    String name = getLabel(indexOf(range));
    fireRadarEvent(name, range);
  }
  
  protected void fireRadarEvent(String name, BoundedRangeModel range)
  {
    RadarEvent event = new RadarEvent(this, name, range);
    ArrayList list = (ArrayList)listeners.clone();
    for (int i = 0; i < list.size(); i++)
    {
      RadarListener listener = (RadarListener)list.get(i);
      listener.radarChanged(event);
    }
  }
}

Listing 2

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

public class RadarView extends JPanel
{
  protected static final double PI2 = Math.PI * 2;

  protected int count;
  protected JRadar radar;
  protected Point2D points[];
  protected Dimension lastSize;
  protected Shape outline;
  protected BufferedImage background;
  protected BufferedImage foreground;
  protected Point2D moveto;

  public RadarView(JRadar radar, int count)
  {
    this.radar = radar;
    this.count = count;
    setPreferredSize(new Dimension(200, 200));
  }
  
  protected void drawRuler(Graphics2D g, Point2D center,
    double angle, double radius, int ticks)
  {
    g.setColor(Color.green);
    g.setTransform(AffineTransform.getRotateInstance(
      angle, center.getX(), center.getY()));
    Line2D line = new Line2D.Double(
      center.getX(), center.getY(),
      center.getX(), center.getY() - radius);
    g.draw(line);
    double step = radius / ticks;
    for (double i = step; i < radius; i += step)
    {
      line = new Line2D.Double(
        center.getX() - 2, center.getY() - i,
        center.getX() + 2, center.getY() - i);
      g.draw(line);
    }
  }
  
  protected Shape createOutlineShape(
    Point2D center, float radius, int count)
  {
    double step = PI2 / (double)count;
    GeneralPath path = new GeneralPath();
    for (double angle = 0; angle < PI2; angle += step)
    {
      double x = center.getX() + Math.sin(angle) * radius;
      double y = center.getY() - Math.cos(angle) * radius;
      if (angle == 0)
      {
        path.moveTo((float)x, (float)y);
      }
      else
      {
        path.lineTo((float)x, (float)y);
      }
    }
    path.closePath();
    return path;
  }
  
  protected void createAmplitudeShape()
  {
    int w = getSize().width - 1;
    int h = getSize().height - 1;
    Point2D center = new Point2D.Float(w / 2, h / 2);
    createAmplitudeShape(center, getRadius(), count);
  }
  
  protected Shape createAmplitudeShape(
    Point2D center, float radius, int count)
  {
    points = new Point2D[count];
    double max = Math.PI * 2;
    double step = max / (double)count;
    GeneralPath path = new GeneralPath();
    for (int i = 0; i < count; i++)
    {
      double angle = i * step;
      double rad = radius * radar.model.getAmplitude(i);
      double x = center.getX() + Math.sin(angle) * rad;
      double y = center.getY() - Math.cos(angle) * rad;
      points[i] = new Point2D.Double(x, y);
      if (angle == 0)
      {
        path.moveTo((float)x, (float)y);
      }
      else
      {
        path.lineTo((float)x, (float)y);
      }
    }
    path.closePath();
    return path;
  }

  protected int getRadius()
  {
    int w = getSize().width - 1;
    int h = getSize().height - 1;
    return Math.min(w / 2, h / 2) - 10;
  }	
  
  public void paintComponent(Graphics gc)
  {
    Graphics2D g = (Graphics2D)gc;
    int w = getSize().width - 1;
    int h = getSize().height - 1;
    Point2D center = new Point2D.Float(w / 2, h / 2);
    int radius = getRadius();
    g.setRenderingHint(
      RenderingHints.KEY_ANTIALIASING ,
      RenderingHints.VALUE_ANTIALIAS_ON);
    if (lastSize == null || !lastSize.equals(getSize()))
    {
      lastSize = getSize();
      outline = createOutlineShape(center, radius, count);
      
      background = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
      Graphics2D bg = (Graphics2D)background.getGraphics();
      bg.setColor(getBackground());
      bg.fillRect(0, 0, w, h);
      bg.setColor(Color.black);
      bg.fill(outline);
      
      foreground = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
      Graphics2D fg = (Graphics2D)foreground.getGraphics();
      double step = PI2 / count;
      for (double angle = 0; angle < PI2; angle += step)
      {
        drawRuler(fg, center, angle, radius, 10);
      }
      fg.setTransform(AffineTransform.getRotateInstance(0));
      drawBevel(fg, outline, new BasicStroke(2), getBackground());
    }
    
    g.drawImage(background, 0, 0, this);
    Shape shape = createAmplitudeShape(center, radius, count);
    g.setPaint(Color.blue);
    g.fill(shape);
    g.drawImage(foreground, 0, 0, this);
  }

  public void drawBevel(Graphics2D g,
    Shape shape, Stroke stroke, Color color)
  {
    Color light = color.brighter();
    Color shade = color.darker();
    
    // We flatten the shape to drop curves.
    PathIterator enum = new FlatteningPathIterator(
      shape.getPathIterator(null), 1);
    g.setStroke(stroke);
    
    while (!enum.isDone())
    {
      Point2D point1 = getPoint(enum);
      enum.next();
      if (enum.isDone()) break;
      Point2D point2 = getPoint(enum);
      int x = (int)(point1.getX() + point2.getX()) / 2;
      int y = (int)(point1.getY() + point2.getY()) / 2;
      g.setColor(shape.contains(x, y) ? light : shade);
      g.draw(new Line2D.Double(point1, point2));
    }
  }

  protected Point2D getPoint(PathIterator enum)
  {
    double[] array = new double[6];
    int type = enum.currentSegment(array);
    if (type == PathIterator.SEG_CLOSE)
      return moveto;
    Point2D point = new Point2D.Double(array[0], array[1]);
    if (type == PathIterator.SEG_MOVETO)
      moveto = point;
    return point;
  }
}

Listing 3

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

public class RadarEdit extends RadarView
  implements MouseListener, MouseMotionListener
{
  protected Point2D selected;
  protected int selectionIndex;
  protected Robot robot;

  public RadarEdit(JRadar radar, int count)
  {
    super(radar, count);
    addMouseListener(this);
    addMouseMotionListener(this);
    try
    {
      robot = new Robot();
    }
    catch (AWTException e) {}
  }

  public void paintComponent(Graphics g)
  {
    super.paintComponent(g);
    if (selected != null)
    {
      int x = (int)selected.getX();
      int y = (int)selected.getY();
      g.setColor(Color.white);
      g.fillRect(x - 3, y - 3, 6, 6);
      g.setColor(Color.black);
      g.drawRect(x - 3, y - 3, 6, 6);
    }
  }
  
  protected void repositionMouse(Point2D selected)
  {
    if (robot == null) return;
    Point point = new Point(
      (int)selected.getX(), (int)selected.getY());
    SwingUtilities.convertPointToScreen(point, this);
    robot.mouseMove(point.x, point.y);
  }

  public void mousePressed(MouseEvent event)
  {
    int radius = getRadius();
    Point point = event.getPoint();
    int w = getSize().width - 1;
    int h = getSize().height - 1;
    Point2D center = new Point2D.Float(w / 2, h / 2);
    double rad = point.distance(center);
    radar.model.setAmplitude(
      selectionIndex, Math.min(rad / radius, 1));
    createAmplitudeShape();
    selected = points[selectionIndex];
    repositionMouse(selected);
  }
  
  public void mouseClicked(MouseEvent event) {}
  public void mouseReleased(MouseEvent event) {}
  public void mouseEntered(MouseEvent event) {}
  public void mouseExited(MouseEvent event)
  {
    selected = null;
    getParent().repaint();
  }
  
  public void mouseMoved(MouseEvent event)
  {
    if (points == null) return;
    Point point = event.getPoint();
    int count = points.length;
    double step = 360 / count;
    double angle = Math.atan2(
      event.getX() - (getSize().width / 2),
      event.getY() - (getSize().height / 2));
    angle = 360 - (Math.toDegrees(angle) + 180);
    selectionIndex = (int)Math.round(angle / step);
    if (selectionIndex < 0 ||
        selectionIndex >= count)
          selectionIndex = 0;
    selected = points[selectionIndex];
    getParent().repaint();
  }
  
  public void mouseDragged(MouseEvent event)
  {
    int radius = getRadius();
    Point point = event.getPoint();
    int w = getSize().width - 1;
    int h = getSize().height - 1;
    Point2D center = new Point2D.Float(w / 2, h / 2);
    double rad = point.distance(center);
    radar.model.setAmplitude(
      selectionIndex, Math.min(rad / radius, 1));
    createAmplitudeShape();
    selected = points[selectionIndex];
    getParent().repaint();
  }
}

Listing 4

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

public class JRadar extends JPanel
  implements RadarListener
{
  protected RadarModel model;

  public JRadar(RadarModel model)
  {
    setModel(model);
    int count = model.getCount();
    setLayout(new CircleLayout());
    for (int i = 0; i < count; i++)
    {
      add(new JLabel(model.getLabel(i)));
    }
    add(BorderLayout.CENTER, new RadarEdit(this, count));
  }
  
  public void setModel(RadarModel model)
  {
    if (this.model != null)
      this.model.removeRadarListener(this);
    this.model = model;
    model.addRadarListener(this);
  }
  
  public RadarModel getModel()
  {
    return model;
  }
  
  public void radarChanged(RadarEvent event)
  {
    repaint();
  }

  public void addRadarListener(RadarListener listener)
  {
    model.addRadarListener(listener);
  }

  public void removeRadarListener(RadarListener listener)
  {
    model.removeRadarListener(listener);
  }
}