ORIGINAL DRAFT

Real-time components are interesting in ways that traditional components can never be. They tend to more closely mimic their real-world counterparts, for example, and they present interesting challenges because of that. If the end-user doesn’t immediately recognize the metaphor, the designers have failed to convey what’s intended. But user interfaces are not the real thing, so replicating expected behavior becomes all the more important in this type of components.

Figure 1: JMeter in difference configurations.

Figure 1: JMeter in difference configurations.

We’ll develop a JMeter component that allows to you present meters with definable labels, ranges and angular positioning in such a way that you can mimic most real-world equivalents. JMeter uses the BoundedRangeModel and is therefore compatible with JSlider and JScrollBar.

Figure 1 shows a number of meters in a JFrame. The JMeter instances demonstrate the quadrants you can constrain meters to. You can have meters cover a single quadrant, all four or anything in between, depending on the angle range you choose. In effect, you can specify any start angle and extent for JMeter instances. I haven’t shown any that aren’t multiples of 90 degrees in this example, but the code supports alternatives if you need them.

One of the trickier issues in JMeter is making sure we make good use of screen real-estate. It’s easy enough to build a component that always requires a full circle, regardless of how small a fraction we actually use, but it’s much more effective to use minimal space. It turns out that you can’t use much less than a given quadrant when you’re dealing with rectangular components. To make it easy to manage quadrants, we’ll develop a special class called QuadrantManager.

Listing 1 shows the code for QuadrantManager. We use four instance variables to flag whether a given quadrant is required, naming them clockwise as ne, se, sw and nw. The constructor takes the start and extent angles and, after making sure the values are in range, flags the first and last quadrant before testing to see which quadrants need to be active. The trick here is to use sequential numbers to represent the range of possibilities, so that quadrants between first and last can be easily calculated.

Other than the toString method, which is there to support printing out the state of our QuadrantManager object, there are two utility methods. The getPreferredSize method lets us calculate the required space for a given radius. Naturally, more space is needed for angles that cover more than one quadrant. The getCenter method takes the component size and insets and returns the center point, depending on which quadrants are required. The center point can be one of the corners, the middle of one side or the center of the entire component, depending on circumstances.

You’ll notice when you look at Figure 1 that there are regions in which we can highlight the tick marks with specific colors, typically green for normal, yellow for caution and red for danger. Because, this behavior should be reusable in other circumstances, we’ll use a renderer solution, which’ll also make it possible for you to write your own if you feel so inclined. The interface for a RadialDivisionRenderer looks like this:

public interface RadialDivisionRenderer
{
  public JComponent getRadialDivisionRendererComponent(
    int minimumValue,
    int maximumValue,
    int normalValue,
    int dangerValue,
    Point centerPoint,
    int insideRadius,
    int outsideRadius,
    Hashtable labels,
    boolean insideLabels,
    int startAngle,
    int extentAngle);
}

Let’s walk through the parameter list. The minimumValue and maximumValue arguments are the low and high values represented by the BoundedRangeModel used by meters. The normalValue indicates the end of the normal range and dangerValue indicates the beginning of the danger range. The centerPoint is self-explanatory. The insideRadius and outsideRadius indicate the radius for the inside and outside tick mark and highlighted region areas. The major tick regions extend a little further inside and outside this range.

The labels hash table is similar to the mechanism used in JSlider, matching up String labels with Integer values. A label at value 100, for example would constitute a String value for the hashtable key: new Integer(100). JMeter generates default labels, but you can create your own if you have more specialized needs. The boolean insideLabels flag determines whether the labels are to be drawn on the inside or outside of the radius. Finally, the startAngle and extentAngle tell us about the physical range of angles we need to work within.

Listing 2 shows the DefaultRadialDivisionRenderer, which implements the RadialDivisionRenderer interface. The constructor does little more than set the font and ensure the component is transparent. The call to getRadialDivisionRendererComponent assigns each of the arguments to an instance variable, to be used when the rendering is done. There are a number of utility methods we use to paint the component but the actual drawing is done in the paintComponent method.

The getLabelOffset method figures out, based on the labels required, how far in or out the labels should be drawn. The value returned represents the furthest distance to the center of a label. The toRadialPoint method does a little trigonometry to translate the axis, angle and radius to a specific point in two dimensional space. The drawLabel method actually draws a label at the given position.

The getArc method is a little more interesting. It creates two pie segment shapes and uses the Java 2D Area object to subtract the smaller from the larger pie, resulting in an arc that can be filled in for a given angle start and end. This is the shape we use to draw the colored regions that highlight the state of our value. Finally, the getAngleForValue method translates between a given value and the angle at which it should be drawn.

If you look back at the paintComponent method you can now see that it simply draws the colored arcs, then the major and minor tick marks, and then the labels. To make it easier to work with angles, notice all the calculations use degrees with a twelve o’clock zero position, incrementing clockwise. The Java APIs are not fully consistent in their use of degrees or radians and the twelve o’clock position seemed more natural to me when I was developing the code.

Listing 3 shows the code for JMeter itself. We maintain instance variables to keep track of a CellRendererPane, which is necessary when using renderers, as well as our RadialDivisionRenderer, the BoundedRangeModel, a QuadrantManager, and properties like the angles, radius, and colors. I haven’t polished up the implementation with accessors for each of these, due to time constraints, but it’s easy enough to add them. You’ll want access to properties like the normal and danger values, and colors, to control the visual display more effectively.

The constructor does a little normalizing on the angles, instantiates a QuadrantManager and a default BoundedRangleModel, calling the createStandardLabels method to set up a default labels hash table. The createStandardLabels method just adds Integer values at intervals of twenty five. We do provide accessors for the model (getModel/setModel methods) because we need to register as a ChangeListener to watch for changes and repaint the view.

The paintComponent methods does most of the real work, relying on a few support methods along the way. We get size parameters, accounting for insets and use the QuadrantManager getCenter method to figure out the center point, after which we get a reference to a Graphics2D object and set the antialiasing property. In order, we step through drawing the background, outside and inside borders, and an inside fill. We then use the RadialDivisionRenderer to draw the tick marks, color highlights, and labels, finally drawing the needle and a shaded circle at the center.

Most of the supporting methods are fairly self explanatory, so I’ll mention only a few. The getPreferredSize and getMinimumSize methods both rely on the QuadrantManager and the radius value to calculate proper dimensions. The drawNeedle methods uses an AffineTransform to properly orient the needle. This is the only place I used this trick and you’ll notice that the needle is drawn close to last. Be careful if you move this, given that the transform may apply to subsequent calls. You don’t want any surprises.

JMeter is a fairly simple component which comes in handy if you need to monitor real-time data. You can use it as a VU meter in audio applications, for example, or monitor more complex server parameters in a management application. The range of applications is quite broad, though meters are rarely found in mainstream desktop applications. My motivation for developing JMeter was to monitor server states in a multi-tiered system architecture. I’m sure your own applications are likely just as rewarding.

Listing 1

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

public class QuadrantManager
{
  protected boolean ne, se, sw, nw;
  
  public QuadrantManager(double start, double extent)
  {
    double end = (start + extent) % 360;
    if (end == 0) end = 360;

    // Determine first quadrant
    int first = 0;
    if (start >= 0 && start < 90) first = 1;
    if (start >= 90 && start < 180) first = 2;
    if (start >= 180 && start < 270) first = 3;
    if (start >= 270 && start < 360) first = 4;
    
    // Determine last quadrant
    int last = 0;
    if (end > 0 && end <= 90) last = 1;
    if (end > 90 && end <= 180) last = 2;
    if (end > 180 && end <= 270) last = 3;
    if (end > 270 && end <= 360) last = 4;
    
    // Flag quadrants between first and last, inclusive
    if (first > last) last += 4;
    ne = (first <= 1 && last >= 1) || (last > 4 && last >= 5);
    se = (first <= 2 && last >= 2) || (last > 4 && last >= 6);
    sw = (first <= 3 && last >= 3) || (last > 4 && last >= 7);
    nw = (first <= 4 && last >= 4) || (last > 4 && last >= 8);
  }

  public Dimension getPreferredSize(int radius)
  {
    int w = radius * 2;
    int h = radius * 2;
    int quadrantCount =
      (ne ? 1 : 0) + (se ? 1 : 0) +
      (sw ? 1 : 0) + (nw ? 1 : 0);
    if (quadrantCount == 1)
    {
      w = radius;
      h = radius;
    }
    if (quadrantCount == 2)
    {
      w = radius * ((nw && ne) || (sw && se) ? 2 : 1);
      h = radius * ((ne && se) || (nw && sw) ? 2 : 1);
    }
    return new Dimension(w, h);
  }
  
  public Point getCenter(Dimension size, Insets insets)
  {
    int x = (size.width - insets.left - insets.right) / 2;
    int y = (size.height - insets.top - insets.bottom) / 2;
    int quadrantCount =
      (ne ? 1 : 0) + (se ? 1 : 0) +
      (sw ? 1 : 0) + (nw ? 1 : 0);
    if (quadrantCount == 1)
    {
      if (ne || se) x = 0;
      if (sw || se) y = 0;
      if (nw || sw) x = size.width;
      if (nw || ne) y = size.height;
    }
    if (quadrantCount == 2)
    {
      if (ne && se) x = 0;
      if (se && sw) y = 0;
      if (nw && sw) x = size.width;
      if (nw && ne) y = size.height;
    }
    return new Point(x, y);
  }
  
  public String toString()
  {
    return "ne=" + ne + ", se=" + se +
      ", sw=" + sw + ", nw=" + nw;
  }
}

Listing 2

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

public class DefaultRadialDivisionRenderer extends JPanel
  implements RadialDivisionRenderer
{
  protected int minimumValue, maximumValue;
  protected int normalValue, dangerValue;
  protected Point centerPoint;
  protected int insideRadius, outsideRadius;
  protected Hashtable labels;
  protected boolean insideLabels;
  protected int startAngle;
  protected int extentAngle;

  protected Color tickColor = Color.white;
  protected Color textColor = Color.white;
  protected Color normalValueColor = Color.green;
  protected Color dangerValueColor = Color.yellow;
  protected Color criticalColor = Color.red;
  
  public DefaultRadialDivisionRenderer()
  {
    setOpaque(false);
    setFont(new Font("Dialog", Font.PLAIN, 10));
  }
  
  public JComponent getRadialDivisionRendererComponent(
    int minimumValue, int maximumValue,
    int normalValue, int dangerValue,
    Point centerPoint,
    int insideRadius, int outsideRadius,
    Hashtable labels, boolean insideLabels,
    int startAngle, int extentAngle)
  {
    this.minimumValue = minimumValue;
    this.maximumValue = maximumValue;
    this.normalValue = normalValue;
    this.dangerValue = dangerValue;
    this.centerPoint = centerPoint;
    this.insideRadius = insideRadius;
    this.outsideRadius = outsideRadius;
    this.labels = labels;
    this.insideLabels = insideLabels;
    this.startAngle = startAngle;
    this.extentAngle = extentAngle;
    return this;
  }
  
  public void paintComponent(Graphics gc)
  {
    Graphics2D g = (Graphics2D)gc;
    g.setRenderingHint(
      RenderingHints.KEY_ANTIALIASING,
      RenderingHints.VALUE_ANTIALIAS_ON);

    g.setColor(normalValueColor);
    g.fill(getArc(centerPoint, 
      getAngleForValue(minimumValue),
      getAngleForValue(normalValue), 
      insideRadius, outsideRadius));
    g.setColor(dangerValueColor);
    g.fill(getArc(centerPoint,
      getAngleForValue(normalValue),
      getAngleForValue(dangerValue),
      insideRadius, outsideRadius));
    g.setColor(criticalColor);
    g.fill(getArc(centerPoint, 
      getAngleForValue(dangerValue),
      getAngleForValue(maximumValue),
      insideRadius, outsideRadius));
    
    g.setColor(tickColor);
    for (int i = 0; i < extentAngle; i += 5)
      drawTick(g, centerPoint, startAngle + i,
      insideRadius, outsideRadius);
    for (int i = 0; i < extentAngle; i += 10)
      drawTick(g, centerPoint, startAngle + i,
      insideRadius - 2, outsideRadius + 2);
    
    g.setColor(textColor);
    Enumeration keys = labels.keys();
    while (keys.hasMoreElements())
    {
      Integer key = (Integer)keys.nextElement();
      String label = (String)labels.get(key);
      int angle = getAngleForValue(key.intValue());
      int rad =
        (insideLabels ? insideRadius : outsideRadius) +
        (insideLabels ? -1 : 1) * getLabelOffset(g);
      drawLabel(g, centerPoint, angle, rad, label);
    }
  }

  protected int getLabelOffset(Graphics2D g)
  {
    Enumeration keys = labels.keys();
    FontMetrics metrics = g.getFontMetrics();
    int w = 0;
    int h = metrics.getAscent();
    while (keys.hasMoreElements())
    {
      Integer key = (Integer)keys.nextElement();
      String label = (String)labels.get(key);
      w = Math.max(w, metrics.stringWidth(label) / 2);
    }
    Point point = new Point(0, 0);
    return (int)point.distance(w, h);
  }

  protected Point toRadialPoint(
    Point axis, int angle, int radius)
  {
    double radian = Math.toRadians(90 - angle);
    int x = (int)(axis.x + Math.cos(radian) * radius);
    int y = (int)(axis.y - Math.sin(radian) * radius);
    return new Point(x, y);
  }
  
  protected void drawLabel(Graphics2D g, Point axis,
    int angle, int radius, String label)
  {
    FontMetrics metrics = g.getFontMetrics();
    int w = metrics.stringWidth(label) / 2;
    int h = metrics.getAscent() / 2;
    
    if (angle == startAngle)
    {
      if (angle >= 0 && angle < 90) w -= w + 2;
      if (angle >= 90 && angle < 180) h += h + 2;
      if (angle >= 180 && angle < 270) w += w + 2;
      if (angle >= 270 && angle < 360) h -= h + 2;
    }
    if (angle == startAngle + extentAngle)
    {
      angle %= 360;
      if (angle == 0) angle = 360;
      if (angle > 0 && angle <= 90) h -= h + 2;
      if (angle > 90 && angle <= 180) w -= w + 2;
      if (angle > 180 && angle <= 270) h += h + 2;
      if (angle > 270 && angle <= 360) w += w + 2;
    }
    Point point = toRadialPoint(axis, angle, radius);
    g.drawString(label, point.x - w, point.y + h - 1);
  }
  
  protected void drawTick(Graphics2D g, Point centerPoint,
    int angle, int insideRadius, int outsideRadius)
  {
    Point a = toRadialPoint(centerPoint, angle, insideRadius);
    Point b = toRadialPoint(centerPoint, angle, outsideRadius);
    g.draw(new Line2D.Double(a, b));
  }
  
  protected Shape getArc(
    Point centerPoint, int start, int end, 
    int insideRadius, int outsideRadius)
  {
    int extent = end - start;
    Arc2D insideLabels = new Arc2D.Double(
      centerPoint.x - insideRadius,
      centerPoint.y - insideRadius,
      insideRadius * 2,
      insideRadius * 2,
      90 - start,
      0 - extent,
      Arc2D.PIE);
    Arc2D outside = new Arc2D.Double(
      centerPoint.x - outsideRadius,
      centerPoint.y - outsideRadius,
      outsideRadius * 2,
      outsideRadius * 2,
      90 - start,
      0 - extent,
      Arc2D.PIE);
    Area area = new Area(outside);
    area.subtract(new Area(insideLabels));
    return area;
  }
  
  protected int getAngleForValue(int value)
  {
    double range = maximumValue - minimumValue;
    return (int)(startAngle +
      (extentAngle / range) *
      (value - minimumValue));
  }
}

Listing 3

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

public class JMeter extends JPanel
  implements ChangeListener
{
  protected CellRendererPane rendererPane =
    new CellRendererPane();
  protected RadialDivisionRenderer renderer =
    new DefaultRadialDivisionRenderer();
  protected BoundedRangeModel model;
  protected Hashtable labels;
  
  protected QuadrantManager quadrant;
  protected int startAngle;
  protected int extentAngle;
  protected int radius;
  
  protected int normal, danger;
  
  protected Color backgroundColor = Color.black;
  protected Color needleColor = Color.white;

  public JMeter(int start, int extent, int radius)
  {
    startAngle = start % 360;
    extentAngle = extent % 360;
    if (extentAngle == 0) extentAngle = 360;
    quadrant = new QuadrantManager(startAngle, extentAngle);
    this.radius = radius;
    normal = 50;
    danger = 75;
    setOpaque(false);
    setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2));
    setModel(new DefaultBoundedRangeModel());
    labels = createStandardLabels();
  }
  
  protected Hashtable createStandardLabels()
  {
    Hashtable table = new Hashtable();
    for (int i = 0; i <= 100; i += 25)
      table.put(new Integer(i), "" + i);
    return table;
  }
  
  public BoundedRangeModel getModel()
  {
    return model;
  }
  
  public void setModel(BoundedRangeModel model)
  {
    if (model != null)
      model.removeChangeListener(this);
    this.model = model;
    model.addChangeListener(this);
  }
  
  public void paintComponent(Graphics gc)
  {
    Insets insets = getInsets();
    int width = getSize().width;
    int height = getSize().height;
    int w = width - (insets.left + insets.right);
    int h = height - (insets.top + insets.bottom);

    Point center = quadrant.getCenter(getSize(), insets);
    
    Graphics2D g = (Graphics2D)gc;
    g.setRenderingHint(
      RenderingHints.KEY_ANTIALIASING,
      RenderingHints.VALUE_ANTIALIAS_ON);
    
    int rad = radius;
    // Draw background
    Shape arc = getArc(center, rad);
    g.setColor(backgroundColor);
    g.fill(arc);

    // Oustide border
    Shape outsideBorder = getArc(center, rad);
    g.setPaint(new GradientPaint(
        center.x - radius, center.y - radius, Color.white,
        center.x + radius, center.y + radius, Color.black));
    g.fill(outsideBorder);
    
    rad -= 2;
    // Inside border
    Shape insideBorder = getArc(center, rad);
    g.setPaint(new GradientPaint(
        center.x - radius, center.y - radius, Color.black,
        center.x + radius, center.y + radius, Color.white));
    g.fill(insideBorder);

    rad -= 2;
    // Inside fill
    Shape centerShape = getArc(center, rad);
    g.setColor(backgroundColor);
    g.fill(centerShape);

    Component rendererComponent =
      renderer.getRadialDivisionRendererComponent(
        model.getMinimum(), model.getMaximum(),
        normal, danger,
        center, radius - 22, radius - 8,
        labels, true, startAngle, extentAngle);
    rendererPane.paintComponent(gc, rendererComponent,
      this, 0, 0, getSize().width, getSize().height);
    
    rad -= 5;
    // Draw needle
    drawNeedle(g, center,
      getAngleForValue(model.getValue()), rad, 5);
    
    int s = 10;
    g.setPaint(new GradientPaint(
      center.x - s, center.y - s, Color.white,
      center.x + s, center.y + s, Color.black));
    g.fillOval(center.x - s, center.y - s, s * 2, s * 2);
  }
  
  public void setValue(int value)
  {
    model.setValue(value);
  }

  public void stateChanged(ChangeEvent event)
  {
    repaint();
  }
  
  protected Shape getArc(Point center, int radius)
  {
    return getArc(center, startAngle, extentAngle, radius);
  }
  
  protected Shape getArc(Point center,
    double start, double extent, int radius)
  {
    return new Arc2D.Double(
      center.x - radius,
      center.y - radius,
      radius * 2, radius * 2,
      90 - start, 0 - extent,
      Arc2D.PIE);
  }
  
  protected int getAngleForValue(int value)
  {
    double range = model.getMaximum() - model.getMinimum();
    return (int)(startAngle + (extentAngle / range) *
      (value - model.getMinimum()));
  }
  
  protected void drawNeedle(Graphics2D g, Point axis,
    double angle, int radius, int thick)
  {
    Polygon polygon = new Polygon();
    polygon.addPoint(0, -thick);
    polygon.addPoint(-thick, 0);
    polygon.addPoint(0, radius);
    polygon.addPoint(thick, 0);
    
    AffineTransform transform = new AffineTransform();
    transform.translate(axis.x, axis.y);
    transform.rotate(Math.toRadians(angle - 180));
    
    Shape shape = transform.createTransformedShape(polygon);
    g.setColor(needleColor);
    g.fill(shape);
  }

  public Dimension getMinimumSize()
  {
    return getPreferredSize();
  }
  
  public Dimension getPreferredSize()
  {
    Insets insets = getInsets();
    Dimension size = quadrant.getPreferredSize(radius);
    size.width += insets.left + insets.right;
    size.height += insets.top + insets.bottom;
    return size;
  }
}