使用JXMapViewer实现地图聚合

来源:岁月联盟 编辑:zhuzhu 时间:2008-12-23

  在过去的几年中,地图绘制技术取得突飞猛进的发展。我们可以将世界地图与照片、视频、卫星图像,甚至与 浴室位置 结合在一起。在 JXMapViewer 的帮助下,我们可以将地图绘制技术应用到自己的桌面 Java 应用程序中。

  在“使用 JXMapViewer 将地图集成到 Swing 应用程序中” 这篇文章中,我向大家介绍了如何使用 JXMapKit(JXMapViewer 的试用版本)构建一个简单的地图绘制应用程序。在本文中,我们将使用 JXMapViewer 定制图形覆盖图、多边形、翻转和地图服务器,然后使用外部 Web 服务搜索 Wikipedia 构建聚合。

  本文不打算介绍如何下载 JXMapViewer.jar 文件,以及如何构建基本地图绘制应用程序。在阅读本文之前,您应该首先阅读 上一篇文章,然后创建一个新的 Java 桌面应用程序并将 JXMapKit 组件添加到您的主面板中。完成这些之后,应用程序将类似于图 1。

使用 JXMapViewer 实现地图聚合

  图 1. 使用 XMapKit i 在 NetBeans 表单编辑器中创建的基本桌面 Java 应用程序

  定制覆盖图

  在上一篇文章中,我向大家介绍了如何使用标准 WaypointPainter 在地图上绘制航点。使用这种方法在自定义地图上绘航点非常出色,但是我们还可以实现更多功能。您可能希望在地图绘制静态文本(静态表示当用户导航地图时它不会移动)或者在航点之间添加一些线条或多边形。由于覆盖图都是 Painters 对象,因此它们基本上只是底层的 Java2D 代码,我们可以任意绘制所需的内容。

  首先,我们在地图上添加一些静态文本, 以对 Blue Marble 地图提供商 NASA 表示感谢:

Painter<JXMapViewer> textOverlay = new Painter<JXMapViewer>() {
  public void paint(Graphics2D g, JXMapViewer map, int w, int h) {
    g.setPaint(new Color(0,0,0,150));
    g.fillRoundRect(50, 10, 182 , 30, 10, 10);
    g.setPaint(Color.WHITE);
    g.drawString("Images provided by NASA", 50+10, 10+20);
  }
};
jXMapKit1.getMainMap().setOverlayPainter(textOverlay);

  上面的 Painter 将在地图的顶部绘制“Images provided by NASA”文本。在 Color 构造函数中添加一个 opacity (alpha) 参数将创建一个半透明的黑色圆角矩形。要在地图上添加这个 Painter 对象,我们应该将它放在 View 的构造函数中(位于 initComponents() 调用后面)。View 是应用程序(本例中为 WikiMashupView.java)自动生成的 main 函数。该函数于构建新桌面 Java 应用程序时创建。setOverlayPainter() 方法将覆盖图添加到地图上。运行应用程序的效果如图 2 所示。

使用 JXMapViewer 实现地图聚合

  图 2. 静态文本覆盖图

  定制多边形覆盖图

  绘制文本和航点固然不错,但是真实有用的还是要根据实际地理数据来绘制内容。假设四个坐标定义了 Sicily 岛的合法边界。我们可以在此地区绘制一个多边形用于指示实际边界。但是,这需要用到一些技巧。要绘制地理坐标,首先需要将地理坐标转换为屏幕坐标。这需要使用复杂的地理转换,并且因地图类型不同其复杂程序也有所不同。我们必须计算用户当前的缩放级别和当前查看的地图位置。幸运的是,JXMapViewer 含有一些可执行这种转换的方法。map.getTileFactory().geoToPixel() 方法可以将 GeoPosition 转换为世界位图上的像素坐标。

  使用 JXMapViewer 时需要理解三个重要的坐标系统。其一,地球上的实际经纬坐标,以度表示。它们使用 GeoPosition 类表示。这是地图坐标系统。TileFactory.geoToPixel() 方法将 GeoPositions 转换为世界位图中的像素坐标(在屏幕特定缩放级别绘制各个像素块[tile]时将创建该位图)。这是世界位图坐标系统。0,0 像素为地图左上角的阿拉斯加附近,而 x/y 像素的最大值为地图右下角的南极洲附近。当然,这并不是在屏幕上所看到的。在屏幕上,我们只能看到世界位图的一部分绘制在 JXMapViewer 的视野中。要计算偏移值,可以调用 JXMapViewer 的 getViewportBounds() 方法获取视野的当前大小和位置。此操作将把点转换为第三种坐标系统:屏幕坐标。屏幕坐标的作用是绘制和接收鼠标输入。理解了如何在这三种坐标系统之间相互转换之后,就可以在地图上绘制任何图形了。

  下面的代码将使用表示 Sicily 岛的四个坐标绘制一个多边形。

final List<GeoPosition> region = new ArrayList<GeoPosition>();
region.add(new GeoPosition(38.266,12.4));
region.add(new GeoPosition(38.283,15.65));
region.add(new GeoPosition(36.583,15.166));
region.add(new GeoPosition(37.616,12.25));
Painter<JXMapViewer> polygonOverlay = new Painter<JXMapViewer>() {
  public void paint(Graphics2D g, JXMapViewer map, int w, int h) {
    g = (Graphics2D) g.create();
    //convert from viewport to world bitmap
    Rectangle rect = map.getViewportBounds();
    g.translate(-rect.x, -rect.y);
    //create a polygon
    Polygon poly = new Polygon();
    for(GeoPosition gp : region) {
      //convert geo to world bitmap pixel
      Point2D pt = map.getTileFactory().geoToPixel(gp, map.getZoom());
      poly.addPoint((int)pt.getX(),(int)pt.getY());
    }
    //do the drawing
    g.setColor(new Color(255,0,0,100));
    g.fill(poly);
    g.setColor(Color.RED);
    g.draw(poly);
    g.dispose();
  }
};

  上述代码定义了四个坐标,然后根据这些坐标绘制了一个多边形。g.translate(-rect.x, -rect.y) 这一行代码很重要,它的作用是将图形上下文转换为世界位图坐标。然后,在 region 循环中,代码调用 map.getTileFactory().geoToPixel() 方法将 GeoPositions 转换为世界位图空间。最后,使用半透明的红色绘制一个多边形并在地图上显示。最后的显示效果如图 3 所示。

使用 JXMapViewer 实现地图聚合

  图 3. Sicily 多边形覆盖图

  我们可以使用 CompoundPainter 将文本覆盖图与多边形覆盖图相结合。CompoundPainter 是一个特殊的 Painter 对象,它可以将任何数量的其他 Painter 聚合到一个栈中,并按顺序绘出。一定要将 cacheable 属性设置为 false,否则当用户拖动地图时多边形 Painter 不会更新。

CompoundPainter cp = new CompoundPainter();
cp.setPainters(textOverlay,polygonOverlay);
cp.setCacheable(false);
jXMapKit1.getMainMap().setOverlayPainter(cp);
航点鼠标悬停

  最初,创建 JXMapViewer 的目的是进行 Aerith JavaOne 演示 。 Aerith 的优异特性之一便是鼠标悬停效果。当鼠标移动到航点附近时,程序将弹出显示一幅缩略图。自 Aerith 发布以来,JXMapViewer 的 API 已经发生了巨大的变化,因此原来的鼠标悬停方法已不再起作用(倒不是希望复制 Aerith 中的方法。由于编写时间紧迫的缘故,这些代码还存在一些不确定的问题)。但是,我们仍然可以使用不同的方法来实现鼠标悬停效果。

  可以使用两种方法实现鼠标悬停效果。其一,可以再安装一个绘图程序(Painter)用于绘制覆盖图。但是,这种方式不支持交互功能,比如说按钮或文本字段。其二,可以使用实际的 Swing 组件。JXMapViewer 是一个 JPanel 子类。因此,要实现鼠标悬停效果,我们可以添加组件作为 JXMapViewer 的子类,并根据鼠标移动到航点附近的条件将它们显示出来。

  下面是鼠标悬停的一个简单示例。当用户将鼠标移动到爪哇岛附近时将显示 JLabel 组件。

final JLabel hoverLabel = new JLabel("Java");
hoverLabel.setVisible(false);
jXMapKit1.getMainMap().add(hoverLabel);
jXMapKit1.getMainMap().addMouseMotionListener(new MouseMotionListener() {
  public void mouseDragged(MouseEvent e) { }
  public void mouseMoved(MouseEvent e) {
    JXMapViewer map = jXMapKit1.getMainMap();
    //location of Java
    GeoPosition gp = new GeoPosition(-7.502778, 111.263056);
    //convert to world bitmap
    Point2D gp_pt = map.getTileFactory().geoToPixel(gp, map.getZoom());
    //convert to screen
    Rectangle rect = map.getViewportBounds();
    Point converted_gp_pt = new Point((int)gp_pt.getX()-rect.x,
                     (int)gp_pt.getY()-rect.y);
    //check if near the mouse
    if(converted_gp_pt.distance(e.getPoint()) < 10) {
      hoverLabel.setLocation(converted_gp_pt);
      hoverLabel.setVisible(true);
    } else {
      hoverLabel.setVisible(false);
    }
  }
});

  上述代码的核心是 mainMap 中的 MouseMotionListener,它将表示爪哇岛的 GeoPosition 转换为屏幕坐标。然后,测试鼠标是否位于转换后的坐标附近,并根据条件显示组件。本例使用的是 JLabel,不过您也可以轻易地使用任何其他组件,也可以使用整个面板。运行应用程序的效果如图 4 所示。

使用 JXMapViewer 实现地图聚合

  图 4. 鼠标接近爪哇岛时将出现 JLabel 组件

  虽然默认情况下 JXMapViewer(和 JXMapKit)看上去相当单调,但是我们可以通过 Painter 或定制绘图代码让它焕然一新。图 5 是我使用 JXMapKit 编写的一个 applet,并使用 Painter 定制了它的外观。该程序还有一些效果无法从图中看出,比如说组件发光效果和航点之间的动画效果。

使用 JXMapViewer 实现地图聚合

  图 5. 焕然一新的JXMapViewer

  使用自定义地图服务器

  JXMapViewer 预先配置了与 Open Street Map 和 Blue Marble 的连接,但是我们也可能需要连接到其他不同的地图服务器。通过将 JXMapKit 的 defaultProvider 属性设置为 Custom,我们可以使用自定义的Tile提供者连接到替代地图服务器。

  JXMapViewer(和 JXMapKit)拥有一个 tileFactory 属性,可以接收 TileFactory 抽象类的实例。实际上,该类是作用是加载图像 Tile 并缓存它们,以及管理线程池。您可以自己创建 TileFactory 类的实现,并完全修改 JXMapViewer 加载图像的方式。不过这需要大量的工作,而且往往是没有必要的。取而代之,我们可以配置 TileFactoryInfo 类的 DefaultTileFactory。TileFactoryInfo 含有大量关于地图的信息(比如说 Tile 的大小和数量)和一个 getTileURL() 方法。通过创建一个匿名的 TileFactoryInfo 子类,我们可以将 JXMapViewer 连接到几乎任何地图服务器。

  假设我们更希望从磁盘上的目录(而不是 Web 服务器)加载 Tile 数据。这些文件位于 /MyHarddrive/test/maptiles/ 目录中,并且使用模式 x_y_z.jpg 命名。我们可以方便地加载这些图像,方法是覆盖 getTileURL 方法并返回合适的 file: URL。

TileFactoryInfo info = new TileFactoryInfo(
    0, //min level
    8, //max allowed level
    10, // max level
    256, //tile size
    true, true, // x/y orientation is normal
    "file:/MyHarddrive/test/maptiles", // base url
    "x","y","z" // url args for x, y & z
    ) {
  public String getTileUrl(int x, int y, int zoom) {
    return this.baseUrl + x+"_"+y+"_"+"zoom"+".jpg";
  }
};
jXMapKit1.setTileFactory(new DefaultTileFactory(info));

  注意 DefaultTileProvider 构造函数中的参数。这些参数详细描述了地图的规格。理解这些参数是相当重要的,因此我将详细介绍之。任何可缩放地图本质上都是一个入栈图像的金字塔。在这个金字塔中,每一层都是前一层相同数据的放大版本。这还意味着每一层中的图像数量都上前一层的四倍。上面构造函数中的前四个数字表示图像金字塔的属性。第一个数字是金字塔的最小放大级别(通常为 0)。第二个数字允许用户放大的最大级别。第三个数字是金字塔的层数(如果允许用户导航整个金字塔层次,则第二个数与第三个数相同)。最后一个数字参数表示各个 Tile 的像素大小(必须为矩形)。

  在这些数字参数之后是两个布尔数(Boolean)和四个字符串(String)。布尔数将设置 x 和 y 轴是正常还是翻转(即从 0 到 N 还是从 0 到N/2)。四个字符串是图像的 Base URL 和 x、y、z 值的 HTTP 参数。getTileUrl() 的默认实现将使用这四个字符串生成图像 URL。如果这些值不够指定图像 URL,那么可以覆盖 getTileUrl() 方法并直接生成 URL 字符串。在上面的示例中,代码使用 baseURL 变量(DefaultTileProvider 中的受保护字段,包含传递给构造函数的 Base URL)和传递给 getTileUrl() 方法的 x、y 和 zoom 参数生成了 file: URL。

  有时地图比上例复杂。比如说,魔兽世界的地图服务器 mapwow.com 类似于标准地图布局,但是它的 Tile 分别存放在两个服务器上,并且缩放级别与有悖于常规。下面是可以连接到 Mapwow Tile 服务器的一个 Tile 配置。

TileFactoryInfo info = new TileFactoryInfo(
    0, //min level
    8, //max allowed level
    9, // max level
    256, //tile size
    true, true, // x/y orientation is normal
    "http://wesmilepretty.com/gmap2/", // base url
    "x","y","z" // url args for x, y and z
    ) {
  public String getTileUrl(int x, int y, int zoom) {
    int wow_zoom = 9-zoom;
    String url = this.baseURL;
    if(y >= Math.pow(2,wow_zoom-1)) {
      url = "http://int2e.com/gmapoutland2/";
    }
    return url + "zoom"+wow_zoom+"maps/" +x + "_" + y + "_" + wow_zoom +".jpg";
  }
};
jXMapKit1.setTileFactory(new DefaultTileFactory(info));

  生成的地图如图 6 所示。

使用 JXMapViewer 实现地图聚合

  图 6. 魔兽世界地图服务器

  由于我们并未删除之前示例中的 Painter,因此生成的魔兽地图中依然保留着 NASA 字样。删除此字段的内容将作为练习留给大家。

  创建聚合

  可以这样说,聚合真正将地理应用程序引入了地图。2005 年 Google Map 刚发布不久,一名企业开发人员便结合 Google Maps 与 Craigslist 创建了一个 Web 应用程序,用于 根据位置显示待出售的住房。 人们将这种类型的数据源混合称作 “聚合”,从此 Web 服务的世界不再是千篇一律。

  在最后一个示例中,我们将 JXMapViewer 与 GeoNames.org 上的一个 Web 服务绑定到一起。该 Web 服务将返回地点与搜索查询字段相关的 Wikipedia 文章,并将这些文章绘制在地图上。为此,我们将使用 NetBeans 6 中的新功能大大简化工作。

  在画布上创建二个搜索字段、标签和搜索按钮,如图 7 所示。为搜索按钮创建一个动作,右键单击该按钮并选择 “Set Action...”。使用 searchWikipedia 作为动作方法名称并选择"Background Task" 复选框,这样动作将使用后台线程。NetBeans 将生成一个含有 SearchWikipediaTask 对象的 searchWikipedia 方法用于处理线程。我们将实际搜索和航点代码保存在 SearchWikipediaTask 类的 doInBackground() 和 succeeded() 方法中。

使用 JXMapViewer 实现地图聚合

  图 7. 含有地图的搜索表单

  创建以上空方法之后,接下来可以编写连接到 GeoNames Web 服务器的代码。GeoNames 搜索 Web 服务器 接收用于搜索 Wikipedia 的查询字符串 (q) 和返回的最大行数 (maxRows)。Web 服务将返回一个包含 entry 元素的 XML 文件。解决该文件的代码如下:

@Override protected Set<WikiWaypoint> doInBackground() {
  try {
    // example: http://ws.geonames.org/wikipediaSearch?q=london&maxRows=10
    URL url = new URL("http://ws.geonames.org/wikipediaSearch?q="+
      jTextField1.getText()+"&maxRows=10");
    XPath xpath = XPathFactory.newInstance().newXPath();
    NodeList list = (NodeList) xpath.evaluate("//entry",
        new InputSource(url.openStream()),
        XPathConstants.NODESET);
    Set<WikiWaypoint> waypoints = new
      HashSet<WikiMashupView.WikiWaypoint>();
    for(int i = 0; i < list.getLength(); i++) {
      Node node = list.item(i);
      String title = (String) xpath.evaluate("title/text()",
        node, XPathConstants.STRING);
      Double lat = (Double) xpath.evaluate("lat/text()",
        node, XPathConstants.NUMBER);
      Double lon = (Double) xpath.evaluate("lng/text()",
        node, XPathConstants.NUMBER);
      waypoints.add(new WikiWaypoint(lat, lon, title));
    }
    return waypoints; // return your result
  } catch (Exception ex) {
    ex.printStackTrace();
    return null;
  }
}

  上述代码将使用 XPath 查询获取所有 entry 元素,然后提取 title、lat 和 lng 元素。对于每个条目,代码将创建一个 WikiWaypoint。WikiWaypoint 是 Waypoint 的一个子类,其中含有一个额外的字段用于存储条目的标题。所有这些代码都位于 SearchWikipediaTask 类的 doInBackground 方法中。故名思义,这个方法将自动在后台线程中运行。因为不应修改后台线程中的 Swing 组件,此方法将返回 WikiWaypoints 的 Set。然后,这个 Set 将传递给 Task(适当的 Swing 线程将自动调用 Task)的 succeeded 方法。下面是 succeeded 方法的实现,该方法使用自定义 WaypointRenderer 绘制地图上的所有航点。

@Override protected void succeeded(Set<WikiWaypoint> waypoints) {
  // move to the center
  jXMapKit1.setAddressLocation(waypoints.iterator().next().getPosition());
  WaypointPainter painter = new WaypointPainter();
  //set the waypoints
  painter.setWaypoints(waypoints);
  //create a renderer
  painter.setRenderer(new WaypointRenderer() {
    public boolean paintWaypoint(Graphics2D g, JXMapViewer map, Waypoint wp) {
      WikiWaypoint wwp = (WikiMashupView.WikiWaypoint) wp;
      //draw tab
      g.setPaint(new Color(0,0,255,200));
      Polygon triangle = new Polygon();
      triangle.addPoint(0,0);
      triangle.addPoint(11,11);
      triangle.addPoint(-11,11);
      g.fill(triangle);
      int width = (int) g.getFontMetrics().getStringBounds(wwp.getTitle(), g).getWidth();
      g.fillRoundRect(-width/2 -5, 10, width+10, 20, 10, 10);
      //draw text w/ shadow
      g.setPaint(Color.BLACK);
      g.drawString(wwp.getTitle(), -width/2-1, 26-1); //shadow
      g.drawString(wwp.getTitle(), -width/2-1, 26-1); //shadow
      g.setPaint(Color.WHITE);
      g.drawString(wwp.getTitle(), -width/2, 26); //text
      return false;
    }
  });
  jXMapKit1.getMainMap().setOverlayPainter(painter);
  jXMapKit1.getMainMap().repaint();
}

  上面的大多数方法都是 Java2D 代码,功能是绘制含有文章标题的半透明的圆角矩形。注意,第一行代码的作用是使地图回到第一个航点的位置。运行应用程序的效果如图 8 所示。

使用 JXMapViewer 实现地图聚合

  图 8. 在 Wikipedia 中搜索 Java

  结束语

  本文中的聚合示例相当简单。要实现高级功能,您可以在其中添加一些缩略图、摘要文本,和到实际 Wikipedia 文章的链接。这个聚合的仅仅表明地图绘制技术和其他 Web 服务的结合是可行的。要了解聚合的其他功能,请访问开发人员门户:Google 和 Yahoo。

  现在,我相信您已经知道如何在 Swing 中使用 JXMapViewer 构建聚合。您可以想到哪些绝妙的应用程序呢?如果您构建了一些有趣的应用程序,请将评论和链接发表在文章下面。我们可以将您的代码保存在 JavaDesktop.org 中。地图绘制技术是未来的一个大产业,而桌面 Java 应用程序desktop Java 现在正处于蓬勃发展之中。