前言
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模式的圖表。