UML软件工程组织

构建可扩展的Java图表组件
turbochen 论坛 来源:天极网
  前言

  Java语言所具有的面向对象特性,使许多复杂的问题可以分解成相对独立的对象来处理。本文用面向对象的方法,将一个图表组件从分解到如何组合,以及如何进行扩展作了详细的讲解。从简单的折线图到稍复杂的多种形状组合的图表,读者可以学到构建一个可扩展的图表组件是多么的容易。
  常见的图表类型

  图表具有很直观的视觉效果,可以方便的用来比较数据的差异、图案和趋势等。

  从外观上来看,常用到的图表主要有散点图、(折)曲线图、柱状图等。本文主要讨论这几种图形样式。其中这每种图又可以与其它的类型组合产生更多的形式。下面以图例来说明:

  先来看散点图:


图1-1

  图1-1是一个典型的散点图,它是由一组X值和一组Y值在二维坐标中两两成对描绘而成。一般这种图形反映两组数据的相关性。例如,要考查钢的硬度与淬火温度的关系,假设上图的横轴表示淬火的温度,纵轴表示同时测出的钢的硬度,这时我们可从上图看出一个趋势,即淬火的温度越高,钢的硬度越大。

  再来看一个折线图:


图1-2

图1-3

  在图1-2的折线图中,假设横轴表示周一到周日,纵轴表示某商场的日销售额。我们可以看出其临近周末的销售额呈急剧上升趋势,到周日开始回落,而最惨淡的是周四。通常折线图也可以表示成柱状图的形式,如图1-3。

  复杂一点的图形


图1-4

图1-5

图1-6

  上图三个图形的数据都是同样的,但它们所能够直观表达的意思又不尽相同。诸如此类的图表,形式多种多样,但它们都是由这几种基本图表组合而成的。

  接下来的一节,我们来看一下组成图表的基本元素有哪些。
  图表的主要元素

  图表的组成

  从前面的例子中我们可以看出,每种图表都是由横坐标轴,纵坐标轴,还有不同的绘图形状组成。为了更容易理解,大家看一下下面的分解图:


上图2-1 下图2-2

  是一个柱状图和折线图的组合图表,我们将它分解之后(图2-2),可以清晰的看到,它是由图表区、坐标轴、网格线、图表形状等组成:

  图表区(Chart):包含所有其它的图表元素。

  坐标轴(Axis):提供绘图形状的坐标参考。一个图表中通常有一个垂直和一个水平坐标轴。而网格线是以坐标轴的刻度为参考,贯穿整个绘图区。网格线同坐标轴一样也可分为水平和垂直网格线。

  图表形状(Plot):也是以坐标轴为参考,按一定的比例将数据按相应形状绘制出来。

  所以,从根本上来说,一个图表的是由三种基本的可视元素组成的:图表区,坐标轴,图表形状。
  实现基本图表元素

  基本图表元素的特征

  我们已经知道了图表的主要组成元素,现在再来看看这些元素有哪些特征。

  还是来看一个图:


图2-3

  从图上我们可以看出,一个位于屏幕坐标系中的图表具有宽度(Wc)和高度(Hc)以及坐标位置(x,y)。图表中的坐标轴也有高度Ha、宽度Wa及坐标位置(x,y)。同样,图表形状也有相应的高度Hp和宽度Wp和坐标位置。

  一个图表通常拥有一个横坐标轴和纵坐标轴。所有的绘图数据的坐标都要转化成适当的屏幕坐标,于是我们需要一个新的元素:比例尺。比例尺应负责完成实际坐标值到屏幕坐标值以及屏幕坐标值到实际坐标值的相互转化。而坐标轴是用来描绘刻度用的,它应与比例尺成对使用。

  一个图表还可以有多个图表形状(如图1-6和图2-1),并且我们可以往图表里面增加或移除形状。一个图表形状应可以表示至少一组以上的数据(如图1-5)。由于图表形状要在图表上描绘数据,它需要有一个东西来记录数据,我们将它称之为数据序列。

  基本图表元素的设计实现

  我们的目标是用程序来实现一个图表。前面的讨论我们已经知道构成图表的基本的元素和它们的特性了。由此我们可以为这几个图表元素设计几个接口类。在设计之前,要首先说明一下,我们不打算实现类似于商业化图表组件的强大交互功能,我们所有的设计,只是为了能阐明问题。

  图表元素接口(ChartWidget)

  因为所有的图表可视元素都有一些共同的属性:位置,宽度和高度,它们还要负责绘制自己本身。所以我们设计一个ChartWidget接口,其它所有可视元素都要继承于这个接口。这个接口的类图如图2-4:


图2-4

  由这个类图,我们可以很容易的写出它的代码:

public interface ChartWidget{
 public int getX();
 public int getY();
 public int getWidth();
 public int getHeight();
 public void draw(Graphics g);
}

  坐标轴(Axis)

  接下来的一个类是坐标轴Axis。坐标轴主要任务是绘制轴及其刻度(Tick)和刻度值,因为它绘制时是按一定的比例绘制的,所以它需要有一个比例尺将实际坐标值转换值成屏幕坐标值。这就引出了Scale这个类。Scale类主要完成实际坐标值到屏幕坐标值以及屏幕坐标值到实际坐标值的相互转化。由此,Axis与Scale是一对相互依赖的类。从设计模式的角度来看,Axis是视图(View),负责界面绘制,Scale就是它的模型(Model),负责提供相应的数据。它们的类图见图2-5:


图2-5

  下面来分别看看Axis类与Scale类的代码:

public abstract class Axis implements ChartWidget
{
protected Scale scale;
protected int x;
protected int y;
protected int width;
protected int height;
protected Axis peerAxis;
protected boolean drawGrid;
protected Color gridColor;
protected Color axisColor;
protected int tickLength;
protected int tickCount;

public Axis()
{
gridColor = Color.LIGHT_GRAY;
axisColor = Color.BLACK;
tickLength = 5;
drawGrid = false;
}

public int getTickCount(){ return tickCount;}
public void setTickCount(int tickCount){this.tickCount=tickCount;}
public Scale getScale(){ return scale;}
public void setScale(Scale scale){ this.scale = scale;}
public int getX(){ return x;}
public void setX(int x){this.x = x;}
public int getY(){ return y;}
public void setY(int y){this.y = y;}
public int getHeight(){ return height;}
public void setHeight(int height){this.height = height;}
public int getWidth(){ return width;}
public void setWidth(int width){this.width = width;}
public boolean isDrawGrid(){return drawGrid;}
public void setDrawGrid(boolean drawGrid){this.drawGrid=drawGrid;}
public Color getAxisColor(){return axisColor;}
public void setAxisColor(Color axisColor){ this.axisColor=axisColor;}
public Color getGridColor(){return gridColor;}
public void setGridColor(Color gridColor){this.gridColor=gridColor;}
public int getTickLength(){return tickLength;}
public void setTickLength(int tickLength){this.tickLength=tickLength;}
public Axis getPeerAxis(){return peerAxis;}
public void setPeerAxis(Axis peerAxis){this.peerAxis = peerAxis;}protected abstract int calculateTickLabelSize(Graphics g);}

public abstract class Scale{
protected double min;
protected double max;
protected int screenMin;
protected int screenMax;
public abstract int getScreenCoordinate(double value);
public double getActualValue(int value)
{
double vrange = max - min;
if(min < 0.0 && max < 0.0)
vrange = (min - max) * -1.0;
double i = screenMax - screenMin;
i = ((double)(value - screenMin) * vrange) / i;
i += min;
return i;
}
public void setMax(double max){this.max = max;}
public void setMin(double min){this.min = min;}
public double getMax(){return max;}
public double getMin(){return min;}
public int getScreenMax(){return screenMax;}
public int getScreenMin(){return screenMin;}
public void setScreenMax(int screenMax){this.screenMax =screenMax;}
public void setScreenMin(int screenMin){this.screenMin = screenMin;}
}

  在上面的Axis类代码中,我们在原有的ChartWidget接口的基础上,为Axis添加了几个其它的属性:轴线的颜色axisColor,网格线的颜色gridColor及网格线的可见属性drawGrid。还有刻度线的长度和个数tickLength和tickCount。而peerAxis属性是参考坐标轴,在绘制坐标轴时的会用到。 Scale类也是抽象的,因为横轴和纵轴的屏幕坐标的转换方式不一样,所以getScreenCoordinate()方法留待子类来实现它。
  图表形状(Plot)

  组成图表还有一个最重要的类,负责描述数据的图表形状,我们称之为Plot。Plot应能绘制多组数据,而这组数据呢,我们专门用一个模型来描述它,这就是DataSeries。由于我们在这里讨论的是二维图表,所以DataSeries应能提供两组分别代表X和Y坐标的数据。还是来看看它们的类图(图2-6):


图2-6

  为了plot能绘制多组数据,除了从ChartWidget继承来的draw(Graphics)方法外,plot还提供了draw(Graphics,DataSeries,int)方法,用来绘制单组的数据。下面的代码更能说明问题:

public abstract class Plot implements ChartWidget
{
 protected int x;
 protected int y;
 protected int width;
 protected int height;
 protected XAxis xAxis;
 protected YAxis yAxis;
 protected ArrayList dataSeries;
 public int getX(){return x;}
 public int getY(){return y;}
 public int getWidth(){return width;}
 public int getHeight(){return height;}
 public void addDataSeries(DataSeries ds)
 {
  dataSeries.add(ds);
 }
 public void removeDataSeries(DataSeries ds)
 {
  dataSeries.remove(ds);
 }
 public void draw(Graphics g)
 {
  for( int i=0;i<dataSeries.size();i++ )
   draw(g,(DataSeries)dataSeries.get(i),i);
 }
 public abstract void draw(Graphics g,DataSeries ds,int index);
}

  Plot类也被设计成了抽象类,具体的绘制方法由子类为实现。而DataSeries类的过于简单,在此我们就不列出代码了。

  图表(Chart)

  最后就是将上面的元素合成一个完整的图表,即Chart类。一个Chart有一个横轴和一个纵轴以及至少一个Plot,并且可以为它添加多个Plot。我们最后来看一下整个Chart及其相关类的UML关系图:


 图2-7

  由于篇幅有限,在此就不列出Chart类的代码了。

  完成一个折线图

  由于前面介绍的只是一些接口或抽象类,要完成一个图表组件,还必须实现它们,下面我们以一个折线图为例,来完成一个完整的折线图。

  实现x轴和y轴

  其实前面的Axis抽象类已经完成一个大部分的操作,余下的就是分别完成x轴和y轴的绘制了。在这里我们就不打算列出完整的类代码,只列出关键的实现部分。

Public class XAxis extends Axis
{
 ……
 public void draw(Graphics g)
 {
  if ( ! (scale instanceof XScale) )
   return;
  int ticks = getTickCount();
  int tickDist = (int) ((double)(scale.getScreenMax()-scale.getScreenMin())/(double)(ticks+1));
  int tickX = scale.getScreenMin();
  int tickY = peerAxis.getScale().getScreenMin();
  int gridLength = peerAxis.getScale().getScreenMax();
  int axisLength = scale.getScreenMax()-scale.getScreenMin();
  /*设置轴线颜色*/
  g.setColor(axisColor);
  /*绘制横轴*/
  g.drawLine(tickX, tickY, tickX+axisLength,tickY);
  for ( int i = 0 ; i < ticks; i++ )
  {
   tickX = scale.getScreenMin()+tickDist*(i+1);
   if ( isDrawGrid() )
   {
    /*如果drawGrid属性为true,用gridColor绘制网格线*/
    g.setColor(gridColor);
    g.drawLine(tickX, tickY , tickX, gridLength );
   }
   /*绘制刻度线*/
   g.setColor(axisColor);
   g.drawLine(tickX, tickY , tickX, tickY+tickLength);
   int tickLabelWidth = g.getFontMetrics().stringWidth(String.valueOf(i+1));
   int tickLabelHeight = g.getFontMetrics().getHeight();
   g.drawString(String.valueOf(i+1), tickX-(tickLabelWidth/2), tickY+tickLabelHeight);
  }
 }
}

public class YAxis extends Axis
{
 public void draw(Graphics g)
 {
  if ( ! (scale instanceof YScale) )
   return;
  int ticks = getTickCount();
  int tickDist = (int) Math.abs((double)(scale.getScreenMax() -  scale.getScreenMin())/(double)(ticks+1));
  int tickY = scale.getScreenMin();
  int tickX = peerAxis.getScale().getScreenMin();
  int gridLength = peerAxis.getScale().getScreenMax();
  int axisLength = scale.getScreenMax();
  /*绘制纵坐标轴*/
  g.setColor(axisColor);
  g.drawLine(tickX, tickY, tickX, axisLength);
  for ( int i = 0 ; i < ticks; i++ )
  {
   tickY = scale.getScreenMin()-tickDist*(i+1);
   if ( isDrawGrid() )
   {
    /*如果drawGrid属性为true,用gridColor绘制网格线*/
    g.setColor(gridColor);
    g.drawLine(tickX, tickY , gridLength, tickY );
   }
   /*绘制刻度线*/
   g.setColor(axisColor);
   g.drawLine(tickX, tickY , tickX-tickLength, tickY);
   int tickLabelWidth = g.getFontMetrics().stringWidth(String.valueOf(i+1));
   g.drawString(String.valueOf(i+1), tickX-tickLength-tickLabelWidth, tickY);
  }
 }
}
  实现画折线的LinePlot

  由于Plot是由DataSeries为它提供绘图数据的,在实现LinePlot之前,先来实现一个DefaultDataSeries类:

public class DefaultDataSeries extends DataSeries
{
 public DefaultDataSeries(Object[] yData) throws InvalidDataException
 {
  super();
  if ( yData == null || !(yData[0] instanceof Double) )
   throw new InvalidDataException();
  for ( int i = 0;i<yData.length;i++ )
  {
   /*将y值添加到序列中*/
   this.yData.add(yData[i]);
   /*根据y值的个数,从1开始自动添加相应数量的x值*/
   this.xData.add(new Double(i+1));
  }
 }
}

  这个DefaultDataSeries提供了一个构造方法,使用者只需提供一组y坐标值,即可构造一个DataSeries了。

  下面是很重要的部分了。我们来看看实现一个画折线的LinePlot是多么的简单:

Public class LinePlot extends Plot
{
 ……
 public void draw(Graphics g, DataSeries ds, int index)
 {
  if ( ds == null ) return;
  g.setColor(lineColor);
  double[] x = new double[ds.size()];
  double[] y = new double[ds.size()];
  int[] xPoints = new int[ds.size()];
  int[] yPoints = new int[ds.size()];
  for ( int i = 0; i< ds.size(); i++ )
  {
   x[i] = ((Double)ds.getXData(i)).doubleValue();
   y[i] = ((Double)ds.getYData(i)).doubleValue();
   /*将ds中的实际值转换成屏幕坐标值*/
   xPoints[i] = xAxis.getScale().getScreenCoordinate(x[i]);
   yPoints[i] = yAxis.getScale().getScreenCoordinate(y[i]);
  }
  /*绘制折线*/
  g.drawPolyline(xPoints, yPoints, xPoints.length);
 }
}


  上面可出了LinePlot中绘制折线的代码,我们看到,绘制一个折线是多么的轻松和简单。

  完成折线图

  通过前面的实现代码,我们来看一个完整的折线图示例:

double[] y = new double[]
{ 12.5,14.1,13.2,11.4,13.25,12.32 };
 try {
  DataSeries ds = new DefaultDataSeries(Primary2ObjectUtil.Doulbe2Object(y));
  XAxis xaxis = new XAxis(new XScale(0,y.length+1),ds.size());
  YAxis yaxis = new YAxis(new YScale(10,15),4);
  xaxis.setDrawGrid(true);
  yaxis.setDrawGrid(true);
  LinePlot plot = new LinePlot(ds,xaxis,yaxis);
  Chart chart = new Chart(xaxis,yaxis,plot);
  JFrame frame = new JFrame("Line Plot Demo");
  frame.setSize(400,300);
  frame.getContentPane().add(chart);
  frame.setVisible(true);
 }
 catch (InvalidDataException e)
 {
  e.printStackTrace();
 }

  下面是这个程序运行起来的屏幕截图:

 
(单组数据的折线图)


(有多组数据的折线图)

  扩展其它类型的图表

  通过前面的例子,我们知道要实现特定类型的图表,只要实现特定的Plot类就可以了。如果数据有特殊格式,只需再扩展一个DataSeries就可以了。为使大家加深理解,我们再以一个柱状图为例子作讲解。

  在第一节的图1-2和图1-3中,我们知道,一组数据除了用折线图表示之外,还可以表示成柱状图的形式。在这里我们就借用折线图的数据,来实现一个BarPlot。下面列出了BarPlot的关键代码:

public class BarPlot extends Plot
{
 ……
 public void draw(Graphics g, DataSeries ds, int index)
 {
 if ( ds == null ) return;
 /*每组柱子的个数*/
 int bars = this.dataSeries.size();
 /*出每个柱子应有的宽度*/
 int barWidth = (int) ((double)xAxis.width/((double)ds.size()+1)/bars-barSpace);
 if ( barWidth <=0 ) barWidth = 1;
 int barx,bary,barw,barh;
 int barGroupWidth = barWidth*bars;
 double ymin = yAxis.getScale().getMin();
 for ( int i = 0;i<ds.size(); i++ )
 {
  barx = (int)(xAxis.getScale().getScreenCoordinate(i+1) - barGroupWidth/2.0d) + index*barWidth;
  double val = ((Double)ds.getYData(i)).doubleValue();
  bary = yAxis.getScale().getScreenCoordinate(val);
  if ( ymin<0) if ( val<0 )
  {
   barh = bary-yAxis.getScale().getScreenCoordinate(0);
   bary = bary-barh;
  }
  else
  {
   barh = yAxis.getScale().getScreenCoordinate(0)-bary;
  }
  else
  {
   barh = yAxis.getScale().getScreenCoordinate(ymin)-bary;
  }
  barw = barWidth; g.setColor(barColor);
  g.fillRect(barx,bary,barw, barh);
  g.setColor(Color.BLACK);
  g.drawRect(barx,bary, barw, barh);
 }
}

  BarPlot的实现比LinePlot稍微复杂一点。主要是要计算每个柱子的位置,宽度和高度。由于考虑到多组柱子以及柱子的值为负数时坐标不同,所以计算要繁索一点。但总体来说,实现BarPlot也是相当简单的。由于柱状图运行代码与折线图类似,这里就不列出演示代码。下面来看看程序在几种情况下的运行画面:


 (单组数据的柱状图)


 (多组数据的柱状图)


 (有负值的柱状图)

  现在我们有了画折线图的类LinePlot和画柱状图的类BarPlot。我们要生成一个折线图与柱状图组合起来的例子。还是来看看代码是如何实现的:

DataSeries ds = new DefaultDataSeries(Primary2ObjectUtil.Doulbe2Object(y1));
XAxis xaxis = new XAxis(new XScale(0,y1.length+1),ds.size());
YAxis yaxis = new YAxis(new YScale(10,15),4);
xaxis.setDrawGrid(true);
yaxis.setDrawGrid(true);
LinePlot linePlot = new LinePlot(ds,xaxis,yaxis);
BarPlot barPlot = new BarPlot(ds,xaxis,yaxis);
/*先生成Bar Chart*/
Chart chart = new Chart(xaxis,yaxis,barPlot);
/*然后将Line Plot加到Bar Chart中*/
chart.addPlot(linePlot);

  代码中,我们先建立了一个Line Plot和一个Bar Plot,再生成了一个Bar Chart,然后再将Line Plot加到Bar Chart中。一个组合图表就简简单单的完成了。来看看:
  实时绘图

  实时绘图最常见的就是股票行情图了。我们不打算在此讲解如何实现这样的股票行情图。为了能说明问题,我们用一个线程定时产生一个数据,模拟实时绘图。

  在此,我们对前面的图表组件进行扩展。这里我们用到了一个设计模式:Observer模式。使用Observer模式可使一个对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。所以在Observer中,关键的对象是被观察目标和观察者。一个观察目标可以有多个观察者。观察者必须事先注册给观察目录。这样当观察目录的状态发生改变时,观察者才有可能被通知到。

  在我们的实时绘图结构中,DataSeries就是被观察目标,而Chart就是观察者。为此,我们设计了一个DataChangeListener接口作为观察者。我们重载了一个Chart来实现DataChangeListener。相应的,我们也重载了DataSeries类,提供注册观察者的机制。下面是它们的结构:



  RealtimeChart对象事先用registerDataChangeListener方法注册给RealtimeDataSeries对象。当RealtimeDataSeries的数据发生改变时,将调用notifyListener方法通知所有已注册的DataChangeListener。Notify方法将依次调用每个已注册DataChangeListener对象的dataChanged方法。如下图:



  在RealtimeChart中,实现了DataChangeListener接口的dataChanged方法:

public class RealtimeChart extends Chart implements DataChangeListener
{
 ……
 /*实现DataChangeListener的方法*/
 public void dataChanged()
 { repaint();
 }
}

  RealtimeChart的dataChanged方法在这里只需简单的重新绘制一次自己。绘制时将自动按新的数据来绘制。利用Observer模式,实时绘图就这样子简单的实现了。

  借助实时绘图的例子,读者可以很容易的自行写一个连接到数据库或者说网络流的绘图程序,在此,我们就不作讲解了。

  结束语

  本文已较完整的讲解了一个可扩展的图表组件的构建过程。读者可以在此基础上扩展自己的组件。例如扩展LinePlot,使它具有可改变线型,线宽,还有点样式等功能。或扩展BarPlot,使它可以用不同的填充模式。你甚至可以扩展Axis来实现3D模式的图表。

 

版权所有:UML软件工程组织