虽然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);
//...
}
而m_image 是用瓦片拼接产生的。当试图初始化、用户缩放、视图Size变化等事件都会触发重新制作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;
}
下载瓦片的代码getTileImage与采用的下载工具高度相关,这里就不赘述拉.
<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;
}
<5> 后续功能
上述实现的是最简单的单线程客户端。在局域网上,问题不大,如果拿到因特网,就要按照更高要求写多线程、本地缓存了。另外,一个底图并不是目的,目的是让其上的各类应用能够方便的搭建。这要求要向用户提供二次开发的支持。可以采用的比如 QT的插件、ActiveX控件等等。这些东西都有设计模式可以参考,大可以自由发挥啦!
------------------------------------
后记--
2008年,偶然机会接触OSM到现在,其在相关的专业领域发挥了非常大的作用。OSM 作为完全开放的地理信息解决方案,还没有形成ArcGIS那样方便的成套的二次开发环境,但是其丰富的数据本身就是最强大的优势,不断更新的数据使他充满活力。相信大家都期待它的进步,开放的力量是无穷的!今后还会继续跟进OSM的应用。