虽然Ajax的Web应用功能强大,但是,很多时候还是需要 C/S模式的客户端程序。最为典型的应用是为现有产品添加新的OSM地图支持(比如替换掉MapX)。很多现有GIS应用都是Native C++的。这些CLient 与网页最大的不同,就是需要即时以及复杂的交互。以OSM为底图,其上需要进行复杂的科学计算,呈现一些网页不容易表现的功能。因此,在NATIVE C++上做一个地图控件是最合适的。
<1> 坐标系统
地图控件本质上是一个窗口(Widget),设计这种控件,最细节、最关键的问题就是坐标转换。对摩卡托投影系的OSM地图而言,由于其比例尺是成倍阶跃的,不存在无级缩放、无缝漫游的要求,设计起来相对简单。
控制当前视图的要素有两个就够了, 一个是比例尺(0-18,整形),一个是视图中心点相对全图的行、列百分比(0.0-1.0)。有了这两个要素,立刻可以计算出需要哪些瓦片来填补背景。
首先,对比例尺 n 来说,图幅长、宽都是 2^(n+8) 像素, n=0 时,就是 256 *256, n=1为 512 * 512。当然,瓦片的行列容积都是 2^n,即 n=0 时就是 1x1,n=1时为 2x2,n=2时为 4*4,以此类推。通过百分比中心点,即可知道中心点位于当前图幅的像素位置:
x = cx * (2^(n+8))
y = cy * (2^(n+8))
同时,知道了中心点的瓦片编号。由于瓦片都是 256 *256 的,则
nx = floor(x /256)
ny = floor(y/256)
而贴图的偏移为
ox = x mod 256
oy = y mod 256
当然了,具体的贴图还要看窗口坐标的轴方向、窗口坐标的原点。但原理是一样的。按照瓦片的坐标偏移,把略微大于视图范围的各个瓦片顺序读出来,表在底图的缓存里,就完成拼接了。
其次,对用户拖动来说,屏幕上像素的拖拽偏移 dx, dy 要换算到归一化的 0-1 全图坐标上。这一步原理很简单。由于比例尺已知,图幅大小已知,比例尺n下,用户拖拽了 dx,dy 像素,相当于整个视图中心移动了
dcx = dx / (2^(n+8))
dcy = dy / (2^(n+8))
至于说符号问题,就是向左为正还是向右为正,还要看屏幕坐标系的朝向。
<2> 与 WGS 84 的转换
第一步里,所有坐标均是与摩卡托线形相关的。但是,与外部程序接口,我们一般用经纬度,这样,需要转换。摩卡托与经纬度的转换,可以看看wiki,里给出转换的类:
#include <math.h> class cProjectionMercator { public: double m_lat,m_lon; double m_x,m_y; static const double R; static const double pi; cProjectionMercator(double v_cood=0,double h_cood=0) :m_lat(v_cood), m_lon(h_cood), m_x(h_cood), m_y(v_cood) { } virtual ~cProjectionMercator(void) { } cProjectionMercator & ToLatLon() { m_lon = 180.0 * (m_x / cProjectionMercator::R) /cProjectionMercator::pi; m_lat = (atan(exp(m_y / cProjectionMercator::R))-cProjectionMercator::pi/4)*2.0/cProjectionMercator::pi*180.0; return *this; } cProjectionMercator & ToMercator() { m_x = cProjectionMercator::R * m_lon* cProjectionMercator::pi /180.0; m_y = cProjectionMercator::R * log ( tan ( m_lat/180.0* cProjectionMercator::pi/2.0 + cProjectionMercator::pi/4 ) ); return *this; } }; const double cProjectionMercator::R=6378137; const double cProjectionMercator::pi=3.1415926535897932384626433832795;
调用:
cProjectionMercator m = cProjectionMercator (31,121).ToMercator(); cProjectionMercator w = cProjectionMercator (-1828374,283726).ToLatLon();
<3> 异步拼接与本地缓存
由于瓦片渲染是要花费时间的,如果界面线程老等待下载完毕,当然会导致访问很卡。所以,我们使用独立的线程来下载数据,并异步的返回到当前视图。为了确保视图的有效性,下载任务需要记录瓦片的比例尺、索引,以及请求这个瓦片的视图的版本。如果用户在尚未下载完毕时就拖动、漫游、缩放,需要通知下载器删除旧版本的任务。
为了防止重复下载瓦片浪费时间和带宽,我们本地需要一个以 n, row, col 为联合 hash 的瓦片索引map,以及一个数据文件。每次请求前,先看看本地的 hash_map里面有木有对应瓦片的偏移,有的话,直接 fseek到本地缓存的位置读取数据,木有的话,要下载,并存在缓存。
<4>动手操练
这里,用QT制作的简单的查看器
4.1 视图控制
其主要的控制变量为三个
protected: //This is the main para for display double m_dCenterX; //percentage, -0.5~0.5 double m_dCenterY; //percentage, -0.5~0.5 int m_nLevel; //0-18初始化为
this->m_dCenterX = this->m_dCenterY = 0; this->m_nLevel = 0;
在每次刷新的时候,即paintEvent里,我们直接刷新存储背景的 m_image 到屏幕。
void tilesviewer::paintEvent( QPaintEvent * /*event*/ ) { QPainter painter(this); //bitblt if (m_image.isNull()==false) painter.drawImage(0,0,m_image); //... }
//public slots for resolution changed events void tilesviewer::on_level_changed(int n) { this->m_nLevel = n; //force update generateBackImage(true); update(); }
<4.2> 背景图像拼接
制作m_image 的函数主要分为以下几步。首先是计算需要显示的Image 究竟由哪些瓦片组成,即左右上下的瓦片编号都是多少。而后,是计算偏移,就是这些瓦片表到底图上时,相对左上角偏移的像素数。最后,是进行拼接。这个方法的代码:
//make a new background image void tilesviewer::generateBackImage(bool need_gen) { m_bNeedReqimage = false; //the boolean mask for generate if (need_gen == false) { if (m_image.isNull()==true) need_gen = true; if (m_image.width()!=this->width()||m_image.height()!=this->height()) need_gen = true; } if (need_gen == false) return; QImage image(this->width(),this->height(),QImage::Format_ARGB32); //then, draw tiles in the image QPainter imagePainter(&image); imagePainter.initFrom(this); imagePainter.setRenderHint(QPainter::Antialiasing, true); imagePainter.setRenderHint(QPainter::TextAntialiasing, true); imagePainter.eraseRect(rect()); //calculate current position int nCenter_X ,nCenter_Y; if (true==this->CV_PercentageToPixel(m_nLevel,m_dCenterX,m_dCenterY,&nCenter_X,&nCenter_Y)) { int sz_whole_idx = 1<<m_nLevel; //current center int nCenX = nCenter_X/256; int nCenY = nCenter_Y/256; //current left top tile idx int nCurrLeftX = floor((nCenter_X-width()/2)/256.0); int nCurrTopY = floor((nCenter_Y-height()/2)/256.0); //current right btm int nCurrRightX = ceil((nCenter_X+width()/2)/256.0); int nCurrBottomY = ceil((nCenter_Y+height()/2)/256.0); //draw images for (int col = nCurrLeftX;col<=nCurrRightX;col++) { for (int row = nCurrTopY;row<=nCurrBottomY;row++) { //generate a image QImage image_source; int req_row = row, req_col = col; if (row<0 || row>=sz_whole_idx) continue; if (col>=sz_whole_idx) req_col = col % sz_whole_idx; if (col<0) req_col = (col + (1-col/sz_whole_idx)*sz_whole_idx) % sz_whole_idx; //query this->getTileImage(m_nLevel,req_col,req_row,image_source); //bitblt int nTileOffX = (col-nCenX)*256; int nTileOffY = (row-nCenY)*256; //0,0 lefttop offset int zero_offX = nCenter_X % 256; int zero_offY = nCenter_Y % 256; //bitblt cood int tar_x = width()/2-zero_offX+nTileOffX; int tar_y = height()/2-zero_offY+nTileOffY; //bitblt imagePainter.drawImage(tar_x,tar_y,image_source); } } //Draw center mark QPen pen(Qt::DotLine); pen.setColor(QColor(0,0,255,128)); imagePainter.setPen(pen); imagePainter.drawLine( width()/2+.5,height()/2+.5-32, width()/2+.5,height()/2+.5+32 ); imagePainter.drawLine( width()/2+.5-32,height()/2+.5, width()/2+.5+32,height()/2+.5 ); } imagePainter.end(); m_image = image; return; }
其关键代码是计算各个瓦片的行列,送给getTileImage下载瓦片。上文调用的CV_PercentageToPixel 方法把 -0.5 ~ 0.5 的中心坐标(与0-1类似)换算到当前比例尺的全图像素坐标下,这个函数主要代码
bool tilesviewer::CV_PercentageToPixel(int nLevel,double px,double py,int * nx,int * ny) { if (!nx || !ny || nLevel<0 || nLevel>18) return false; if (px<-0.5 || px>0.5 || py<-0.5 || py>0.5) return false; //calculate the region we need //first, determine whole map size in current level int sz_whole_idx = 1<<nLevel; int sz_whole_size = sz_whole_idx*256; //calculate pix coodinats int nCenter_X = px * sz_whole_size+sz_whole_size/2+.5; if (nCenter_X<0) nCenter_X = 0; if (nCenter_X>=sz_whole_size) nCenter_X = sz_whole_size-1; int nCenter_Y = py * sz_whole_size+sz_whole_size/2+.5; if (nCenter_Y<0) nCenter_Y = 0; if (nCenter_Y>=sz_whole_size) nCenter_Y = sz_whole_size-1; *nx = nCenter_X; *ny = nCenter_Y; return true; }
<4.3> 经纬度到视图的互换
为了实现和经纬度的坐标转换,视图坐标首先被转换为摩卡托,摩卡托接着转换为经纬度。反之亦然。这个功能决定了能否按照经纬度在地图上裱画额外的东东。
bool tilesviewer::oTVP_LLA2DP(double lat,double lon,qint32 * pX,qint32 *pY) { if (!pX||!pY) return false; //到墨卡托投影 double dMx = cProjectionMercator(lat,lon).ToMercator().m_x; double dMy = cProjectionMercator(lat,lon).ToMercator().m_y; //计算巨幅图片内的百分比 double dperx = dMx/(cProjectionMercator::pi*cProjectionMercator::R*2); double dpery = -dMy/(cProjectionMercator::pi*cProjectionMercator::R*2); double dCurrImgSize = pow(2.0,m_nLevel)*256; //计算要转换的点的巨幅图像坐标 double dTarX = dperx * dCurrImgSize + dCurrImgSize/2; double dTarY = dpery * dCurrImgSize + dCurrImgSize/2; //计算当前中心点的巨幅图像坐标 double dCurrX = dCurrImgSize*m_dCenterX+dCurrImgSize/2; double dCurrY = dCurrImgSize*m_dCenterY+dCurrImgSize/2; //计算当前中心的全局坐标 double nOffsetLT_x = (dCurrX-width()/2.0); double nOffsetLT_y = (dCurrY-height()/2.0); //判断是否在视点内 *pX = dTarX - nOffsetLT_x+.5; *pY = dTarY - nOffsetLT_y+.5; if (*pX>=0 && *pX<width()&&*pY>=0&&*pY<height()) return true; return false; } bool tilesviewer::oTVP_DP2LLA(qint32 X,qint32 Y,double * plat,double * plon) { if (!plat||!plon) return false; //显示经纬度 //当前缩放图幅的像素数 double dCurrImgSize = pow(2.0,m_nLevel)*256; int dx = X-(width()/2+.5); int dy = Y-(height()/2+.5); double dImgX = dx/dCurrImgSize+m_dCenterX; double dImgY = dy/dCurrImgSize+m_dCenterY; double Mercator_x = cProjectionMercator::pi*cProjectionMercator::R*2*dImgX; double Mercator_y = -cProjectionMercator::pi*cProjectionMercator::R*2*dImgY; double dLat = cProjectionMercator(Mercator_y,Mercator_x).ToLatLon().m_lat; double dLon = cProjectionMercator(Mercator_y,Mercator_x).ToLatLon().m_lon; if (dLat>=-90 && dLat<=90 && dLon>=-180 && dLon<=180) { *plat = dLat; *plon = dLon; return true; } return false; }
上述实现的是最简单的单线程客户端。在局域网上,问题不大,如果拿到因特网,就要按照更高要求写多线程、本地缓存了。另外,一个底图并不是目的,目的是让其上的各类应用能够方便的搭建。这要求要向用户提供二次开发的支持。可以采用的比如 QT的插件、ActiveX控件等等。这些东西都有设计模式可以参考,大可以自由发挥啦!
------------------------------------
后记--
2008年,偶然机会接触OSM到现在,其在相关的专业领域发挥了非常大的作用。OSM 作为完全开放的地理信息解决方案,还没有形成ArcGIS那样方便的成套的二次开发环境,但是其丰富的数据本身就是最强大的优势,不断更新的数据使他充满活力。相信大家都期待它的进步,开放的力量是无穷的!今后还会继续跟进OSM的应用。