前言 今天整理资料,看到了之前自己用Java画了张雷达图,遂又拿来研究了下,感觉比较好玩,特地分享一下。
众所周知,Java在绘制图像方面的能力是比较差(la)劲(ji)的。自带的主要的一些图像处理类有Java2D中绘图类Graphics,图像流处理类ImageIO等。
有一些基于Java的图像处理开源包也仅仅是对原图像进行缩放、变化、水印等操作,使用Java进行绘图的少之又少。
今天我们用Java自带的图像处理类(Graphics、ImageIO等)来绘制一张雷达图吧。
正文 为什么要画雷达图?而不是用Java绘制动漫人物?
咳咳……因为雷达图应用广泛(就不要为难Java画动漫人物了TAT)。
我们开始吧,先随便看一张雷达图。
可知其主要内容:
外层环、圆环数量、圆环半径、分类名称、各个部分的数值、各种颜色等等很多很多属性。
这里,我们不妨绘制一个考试分数雷达图,这样更结合实际。
我们需要一个Java Bean,里面存放雷达图的一些属性参数。如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 public class RadarMapInit { private int cirNum=2 ; private int r=200 ; private Point point=new Point(300 ,300 ); private Color cirColor=new Color(24 ,165 ,255 ); private float startAngle=-90 ; private Color spoColor=new Color(0 ,0 ,255 ,100 ); private Graphics2D g; private Color fillColor=new Color(146 ,199 ,234 ,200 ); private Color cenColor=Color.WHITE; private String cenText; private Font cenFont=new Font("宋体" , Font.TYPE1_FONT, 50 ); private Color outColor=Color.BLUE; private Font outFont=new Font("宋体" , Font.TYPE1_FONT,12 ); private String imgPath; private String imgType="jpg" ; private int picWidth=600 ; private int picHeight=700 ; private int shiftX=100 ; private int shiftY=100 ; private Color picBackColor=Color.WHITE; private int lineMaxVal=1000 ; private int lineCurVal=-1 ; private int lineStaVal=0 ; private Color lineArrColor=new Color(24 ,165 ,255 ); private Color lineNotArrColor=Color.GRAY; private BasicStroke lineStroke=new BasicStroke(5.0f ,BasicStroke.CAP_ROUND,BasicStroke.JOIN_MITER); private Font genLineFont=new Font("宋体" , Font.TYPE1_FONT,12 ); private Font speLineFont=new Font("宋体" , Font.TYPE1_FONT,25 ); private String speLineValue; private String speLineText; private Color genTextColor=Color.BLACK; private Color speTextColor=Color.BLUE; ...... }
上面省略掉了get set和构造方法。
另外我里面还有一些参数设置没提到的,主要是用来展示效果让其更人性化 (应付客户使其更加满意)的,比如底部的分数击败直线,雷达图中央的分数显示等,一会儿大家可以看到效果。
参数很多,没办法……
对于每个分类,我们也新建一个属于它们的Java Bean,用来存储它们的属性。(PS:因为到底有多少分类是不确定的,故应该在画图时传入一个Bean List)。
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class RadarMapData { private String value; private String maxValue; private String group; private String textValue; private String picPath; ...... }
同样省略了Get Set 和构造方法,我又增加了图片路径可以在分类的文字部分添加小图标,还可以对分类进行分组(比如数学是理科分组,语文英语是文科分组),可以说很Nice了 (以应对莫名其妙的需求)。
好了,开始使用我们的Graphics类进行绘图等操作了。
首先,这个关键类要使用我们刚才的那两个Bean,应该如下代码,同样Get,Set和构造方法略。
1 2 3 4 5 6 7 8 9 10 11 12 public class RadarMap { private Logger logger = LoggerFactory.getLogger(RadarMap.class); private List<RadarMapData> dataList; private RadarMapInit init; ...... }
开始绘制逻辑,首先有个画圆环方法,可以画出数个同心圆,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private void drawCircles () { Graphics2D g = init.getG(); g.setColor(init.getCirColor()); int x = init.getPoint().x; int y = init.getPoint().y; for (int i = 1 ; i <= init.getCirNum(); i++) { int d = 2 * i * init.getR() / init.getCirNum(); g.drawOval(x - d / 2 , y - d / 2 , d, d); } }
主要方法就是g.drawOval画圆,不在详细解释。
然后我们以圆心绘制分类的每条射线,同时拿到每个分类的最大数值和这个人的数值并标记,然后连接这个人的各个数值,并将这个多边形内部填充起来。
如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 private void drawSpokes () { Graphics2D g = init.getG(); Point point = init.getPoint(); int num = dataList.size(); g.setColor(init.getSpoColor()); float angle = init.getStartAngle(); float angleStep = 360 / num; for (int i = 1 ; i <= num; i++) { Point pt = getMappedPoint(init.getR(), angle, point); g.drawLine(point.x, point.y, pt.x, pt.y); addCirWordPic(dataList.get(i - 1 ).getTextValue(), pt, angle, dataList.get(i - 1 ).getPicPath()); g.setColor(init.getSpoColor()); angle += angleStep; } Polygon p = new Polygon(); g.setColor(init.getFillColor()); float angle1 = init.getStartAngle(); Point ptNext = new Point(); for (int i = 1 ; i <= num; i++) { int myR = (int )(Double.parseDouble(dataList.get(i - 1 ).getValue())*(double )init.getR()/Double.parseDouble(dataList.get(i - 1 ).getMaxValue())); Point pt = getMappedPoint(myR, angle1, point); p.addPoint(pt.x, pt.y); if (i < num) { angle1 += angleStep; int myRNext = (int )(Double.parseDouble(dataList.get(i).getValue())*(double )init.getR()/Double.parseDouble(dataList.get(i).getMaxValue())); ptNext = getMappedPoint(myRNext, angle1, point); } else { angle1 += angleStep; int myRNext = (int )(Double.parseDouble(dataList.get(0 ).getValue())*(double )init.getR()/Double.parseDouble(dataList.get(0 ).getMaxValue())); ptNext = getMappedPoint(myRNext, angle1, point); } } g.drawPolygon(p); g.fillPolygon(p); }
这个方法首先解析分类数组,有几个就画几条射线(根据角度,这里逆时针旋转,起始角度-90,也就是从正上方开始旋转),从圆心与角度点之间画直线,并为这条直线的终点(角度点)添加文字和图片(分类文字及图片,方法addCirWordPic)。
完成后对于各个实际值点(实际分,比如语文100分,实际78分,78就是这儿的实际值),绘制成多边形。 (PS: Polygon p = new Polygon();绘制多边形,p.addPoint为多边形添加指定点,g.drawPolygon(p)为绘制多边形,g.fillPolygon(p)为多边形填充颜色)
上面方法调用了两个方法getMappedPoint和addCirWordPic,大家可以看下,分别为寻找绘图点方法和添加分类图片文字方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 private Point getMappedPoint (int r, float angle, Point point) { Point pt = new Point(); pt.x = (int ) (r * Math.cos(angle * Math.PI / 180 ) + point.x); pt.y = (int ) (r * Math.sin(angle * Math.PI / 180 ) + point.y); return pt; } private void addCirWordPic (String wordValue, Point point, float angle, String picValue) { Graphics2D g = init.getG(); double x = 0 ; double y = 0 ; double dx = 0 ; double dy = 0 ; if (wordValue == null || "" .equals(wordValue)) { logger.info(angle + "角度的点没有配置圆外文字" ); Map<String, Double> map = setDxDy(angle, x, y, dx, dy); dx = map.get("dx" ); dy = map.get("dy" ); } else { FontRenderContext context = g.getFontRenderContext(); Font font = init.getOutFont(); g.setColor(init.getOutColor()); g.setFont(font); Rectangle2D bounds = font.getStringBounds(wordValue, context); x = (bounds.getWidth()) / 2 ; y = (bounds.getHeight()) / 2 ; Map<String, Double> map = setDxDy(angle, x, y, dx, dy); dx = map.get("dx" ); dy = map.get("dy" ); g.drawString(wordValue, (int ) (point.x + dx), (int ) (point.y + dy)); } if (picValue == null || "" .equals(picValue)) { logger.info(angle + "角度的点没有配置圆外图片" ); return ; } ImageIcon imgIcon = new ImageIcon(picValue); Image img = imgIcon.getImage(); g.drawImage(img, (int ) (point.x + dx), (int ) (point.y + dy - 5 * y), null ); } private Map<String, Double> setDxDy (float angle, double x, double y, double dx, double dy) { double cosVal = Math.cos(angle * Math.PI / 180 ); double sinVal = Math.sin(angle * Math.PI / 180 ); if (cosVal > 0 && sinVal > 0 ) { dx = 5 ; dy = 2 * y + 20 ; } else if (cosVal > 0 && sinVal < 0 ) { dx = 5 ; dy = -5 ; } else if (cosVal < 0 && sinVal > 0 ) { dx = -2 * x - 5 ; dy = 2 * y + 20 ; } else if (cosVal < 0 && sinVal < 0 ) { dx = -2 * x - 5 ; dy = 5 ; } Map<String, Double> map = new HashMap<String, Double>(); map.put("dx" , dx); map.put("dy" , dy); return map; }
绘制分类图片及文字时,由于要考虑文字大小,图片宽度等要求,故对图片及文字位置做了微调,会显得代码多些。
然后我们再对圆心加上一些文字,比如总分多少分什么的,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private void addWord () { Graphics2D g = init.getG(); String value = init.getCenText(); if (value == null || "" .equals(value)) { logger.info("雷达图没有配置圆心文字,不添加圆心文字" ); return ; } FontRenderContext context = g.getFontRenderContext(); Font font = init.getCenFont(); g.setColor(init.getCenColor()); g.setFont(font); Rectangle2D bounds = font.getStringBounds(value, context); double x = (bounds.getWidth()) / 2 ; double y = (bounds.getHeight()) / 2 ; g.drawString(value, (int ) (init.getPoint().x - x), (int ) (init.getPoint().y + y - 10 )); }
总的来说就是找到圆心位置,根据字体大小,对字体位置进行调整。
然后我们再来绘制下面的跑分直线部分,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 private void drawLineAndWord () { int curVal=init.getLineCurVal(); if (curVal==-1 ) { return ; } int staVal=init.getLineStaVal(); int maxVal=init.getLineMaxVal(); Graphics2D g = init.getG(); int width=init.getPicWidth(); int shiftX=init.getShiftX(); int shiftY=init.getR()+init.getPoint().y+init.getShiftY(); Point point=new Point(shiftX,shiftY); int lineCur=(width-2 *shiftX)*curVal/maxVal; int lineMax=(width-2 *shiftX)*maxVal/maxVal; Point p1=getMappedPoint(lineCur,0 ,point); Point p2=getMappedPoint(lineMax,0 ,point); g.setColor(init.getLineArrColor()); g.setStroke(init.getLineStroke()); g.drawLine(point.x,point.y,p1.x,p1.y); g.setColor(init.getLineNotArrColor()); g.drawLine(p1.x,p1.y,p2.x,p2.y); drawWord(g,init.getGenLineFont(),init.getGenTextColor(),point,staVal+"" ,-10 ,0 ); drawWord(g,init.getGenLineFont(),init.getGenTextColor(),p2,maxVal+"" ,20 ,0 ); drawWord(g,init.getGenLineFont(),init.getSpeTextColor(),p1,curVal+"" ,0 ,15 ); drawArrow(g,init.getLineArrColor(),p1); Point speTextP=new Point(shiftX-5 ,shiftY+60 ); drawWordNormal(g,init.getSpeLineFont(),init.getGenTextColor(),init.getSpeTextColor(),speTextP,init.getSpeLineText()+"的分数击败了全班" ,init.getSpeLineValue(),"的童鞋!" ); }
这个线就是比分用的,比如小明总分500分,击败了全班80%的童鞋,这种直线及文字。 也没啥内容,主要就是绘制直线,要求变换直线宽度及颜色,然后在线的两端添加文字,然后在底部添加必要文字。 (PS:这儿的底部文字可以设置的,我直接写死了,其实也可以当参数传进来)
这个方法里面也用到了几个小方法,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 private void drawWord (Graphics2D g,Font font,Color color,Point point,String value,double dx,double dy) { FontRenderContext context = g.getFontRenderContext(); Rectangle2D bounds = font.getStringBounds(value, context); g.setFont(font); g.setColor(color); double x = (bounds.getWidth()) / 2 ; double y = (bounds.getHeight()) / 2 ; g.drawString(value, (int ) (point.x - x+dx), (int ) (point.y + y+dy)); } private void drawWordNormal (Graphics2D g,Font font,Color genColor,Color speColor,Point point,String firstValue,String cenVal,String endValue) { FontRenderContext context = g.getFontRenderContext(); Rectangle2D bounds1 = font.getStringBounds(firstValue, context); Rectangle2D bounds2 = font.getStringBounds(cenVal, context); double x1 = bounds1.getWidth(); double x2 = bounds2.getWidth(); g.setFont(font); g.setColor(genColor); g.drawString(firstValue, (int ) (point.x), (int ) (point.y)); g.setColor(speColor); g.drawString(cenVal, (int ) (point.x+x1), (int ) (point.y)); g.setColor(genColor); g.drawString(endValue, (int ) (point.x+x1+x2), (int ) (point.y)); } private void drawArrow (Graphics2D g,Color color,Point point) { g.setColor(color); Polygon p = new Polygon(); p.addPoint(point.x,point.y); p.addPoint(point.x-10 ,point.y-20 ); p.addPoint(point.x,point.y-15 ); p.addPoint(point.x+10 ,point.y-20 ); g.fillPolygon(p); }
相当于对一些细节的操作优化,这儿就不一一介绍了。
然后在程序出图之前,我们先用Java中比较古老的JFrame看看图片的效果,方便调试“修图”。
在RadarMap里添加如下方法,这个方法只有JFrame测试时用到,当图调好了就可以删了,如下:
1 2 3 4 5 6 7 8 9 public void drawRadarMap () { drawCircles(); drawSpokes(); addWord(); drawLineAndWord(); }
很简单,依次调用上面的几个方法。
我们新建一个JFrame Test类,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 public class MyTest extends JFrame { MyPanel mp = null ; public static void main (String[] args) { MyTest demo01 = new MyTest(); } public MyTest () { mp = new MyPanel(); this .add(mp); this .setSize(600 , 700 ); this .setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); this .setVisible(true ); } } class MyPanel extends JPanel { public void paint (Graphics g) { super .paint(g); List<RadarMapData> dataList = new ArrayList<RadarMapData>(); RadarMapData map1 = new RadarMapData("100" ,"150" , "" , "数学" , "" ); RadarMapData map2 = new RadarMapData("120" ,"150" , "" , "语文" , "" ); RadarMapData map3 = new RadarMapData("90" ,"150" , "" , "英语" , "" ); RadarMapData map4 = new RadarMapData("80" ,"100" , "" , "物理" , "" ); RadarMapData map5 = new RadarMapData("95" ,"100" , "" , "化学" , "" ); RadarMapData map6 = new RadarMapData("88" ,"100" ,"" , "生物" , "" ); RadarMapData map7 = new RadarMapData("66" ,"100" , "" , "历史" , "" ); RadarMapData map8 = new RadarMapData("77" ,"100" , "" , "政治" , "" ); RadarMapData map9 = new RadarMapData("45" ,"100" , "" , "地理" , "" ); RadarMapData map10 = new RadarMapData("88" ,"100" , "" , "音乐" , "" ); RadarMapData map11 = new RadarMapData("80" ,"100" , "" , "体育" , "" ); RadarMapData map12 = new RadarMapData("100" ,"100" , "" , "美术" , "" ); dataList.add(map1); dataList.add(map2); dataList.add(map3); dataList.add(map4); dataList.add(map5); dataList.add(map6); dataList.add(map7); dataList.add(map8); dataList.add(map9); dataList.add(map10); dataList.add(map11); dataList.add(map12); int lineMaxValue = 0 ; int currentValue = 0 ; for (RadarMapData data:dataList){ lineMaxValue+=Integer.valueOf(data.getMaxValue()); currentValue+=Integer.valueOf(data.getValue()); } RadarMapInit init = new RadarMapInit(); init.setCirNum(5 ); init.setCenText(currentValue+"分" ); init.setSpeLineValue("60%" ); init.setG((Graphics2D) g); init.setLineCurVal(currentValue); init.setLineMaxVal(lineMaxValue); RadarMap test = new RadarMap(dataList, init); test.drawRadarMap(); } }
这个Test主要就是给一些参数赋值。然后测试,可以看到结果如下图所所示,哈哈,还是蛮不错的。
这我们只在JFrame上测试了,我们的任务是生成图片,然后开始吧,在RadarMap里添加如下方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public boolean createImage () { boolean flag = false ; try { File file = new File(init.getImgPath()); int width=init.getPicWidth(); int height=init.getPicHeight(); BufferedImage bi = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2 = (Graphics2D) bi.getGraphics(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2.setBackground(init.getPicBackColor()); g2.clearRect(0 , 0 , width, height); init.setG(g2); drawCircles(); drawSpokes(); addWord(); drawLineAndWord(); flag = ImageIO.write(bi, init.getImgType(), file); } catch (Exception e) { flag = false ; e.printStackTrace(); } return flag; }
这个方法主要就是利用了BufferedImage来生成一张图片。我们把刚才JFrame里的两行注掉的代码开启。
再次启动JFrame类,可以看到生成图片啦。
哈哈,蛮不错的……
然后我们把你的分数……这句话的你动态传入姓名(比如小红……)。 然后新建分数MapTest如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 public class ScoreMapTest { private static final String [] names = {"小一" ,"小二" ,"小三" ,"小四" ,"小五" ,"小六" ,"小七" ,"小八" ,"小九" ,"小十" }; private static int randomValue (int maxValue) { Random random = new Random(); return random.nextInt(maxValue); } public static void drawMapPic () { for (String name:names){ List<RadarMapData> dataList = new ArrayList<RadarMapData>(); RadarMapData map1 = new RadarMapData(randomValue(150 )+"" ,"150" , "" , "数学" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map2 = new RadarMapData(randomValue(150 )+"" ,"150" , "" , "语文" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map3 = new RadarMapData(randomValue(150 )+"" ,"150" , "" , "英语" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map4 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "物理" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map5 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "化学" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map6 = new RadarMapData(randomValue(100 )+"" ,"100" ,"" , "生物" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map7 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "历史" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map8 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "政治" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map9 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "地理" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map10 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "音乐" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map11 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "体育" , "/Users/zhangwentong/Desktop/book.png" ); RadarMapData map12 = new RadarMapData(randomValue(100 )+"" ,"100" , "" , "美术" , "/Users/zhangwentong/Desktop/book.png" ); dataList.add(map1); dataList.add(map2); dataList.add(map3); dataList.add(map4); dataList.add(map5); dataList.add(map6); dataList.add(map7); dataList.add(map8); dataList.add(map9); dataList.add(map10); dataList.add(map11); dataList.add(map12); int lineMaxValue = 0 ; int currentValue = 0 ; for (RadarMapData data:dataList){ lineMaxValue+=Integer.valueOf(data.getMaxValue()); currentValue+=Integer.valueOf(data.getValue()); } RadarMapInit init = new RadarMapInit(); init.setCirNum(5 ); init.setCenText(currentValue+"分" ); init.setSpeLineText(name); init.setSpeLineValue("80%" ); init.setImgPath("/Users/zhangwentong/Desktop/map/image" +name+".jpg" ); init.setLineCurVal(currentValue); init.setLineMaxVal(lineMaxValue); RadarMap test = new RadarMap(dataList, init); test.createImage(); } } public static void main (String[] args) { drawMapPic(); } }
同时添加图标(我这里只添加了一个相同的,不同的也是可以的),新建map文件夹(用于存放雷达图)。如下:
运行测试类。打开map文件夹。
随便选一张查看。
总结 通过使用Java2D绘制雷达图,学到了Java2D的一些用途吧,虽然Java2D使用的很少,而且以后估计用的概率也不大,但是,我们就当一次对于Java程序的自娱自乐吧!
PS:Java图像处理方面确实很差,因为它的着重点不是这儿,而是大型Web项目,这篇文章的目的也不在于去理解Java2D的一些用途,而是通过一些学习,让我们知道,Java也是可以做一些莫名其妙的事情的,虽然不尽人意,也是,世界上哪有一种编程语言是完美的呢?