(相关的代码可以从https://github.com/goldenhawking/mercator.qtviewer.git直接克隆)
我们现在是准备做一个C/S架构的地图显示控件,就必然牵扯到坐标系和UI的界面控制。
目前osm采用墨卡托投影,这个投影的原理可以用一个假想实验解释。
假设地球是一个透明的球体,在球体的球心有一个光源。我们把一张幕布沿着赤道卷起来,使之与地球内切,地球上的一个点在这块幕布上的投影就是其墨卡托投影位置。
上图中,地球半径是R=6378137米,可想而知,圆柱顶面周长为 2 pi R。我们以0度经线投影为中轴,用剪刀沿着180度经线投影剪开,即可展开形成地图面。这个地图面的中心与地理位置 (0,0)重合;X轴是赤道,长度为 2 * Pi * R,取值范围 -pi R 到 pi R,即 -20037508 ~ + 20037508 米;Y轴是本初子午线投影,点光源直接照射球迷质点形成的影子具有拉伸特性,高纬度地区拉伸非常严重。其拉伸效果是 y = R ln (tan (pi/4 + lat/2)),在南极、北极存在奇点。对一般的瓦片地图而言,为了方便计算机处理,一般y的取值范围也是 -20037508 ~ + 20037508 米,反推回去,对应纬度范围只能表示到 -85度~85度。
上面所说的墨卡托投影完成了从地球上的一点到虚拟圆柱上一点的映射。然而,为了使用计算机存储、访问地图,就必须引入采样。所谓的采样,即使用离散的栅格像素表示连续的地理空间数据。我们目前所见的OpenStreetMap采用了19层比例尺,标号为 0 ~ 18.
在0级,整个世界地图被缩略为一块 256x256 的位图。在1级,我们把分辨率提高一倍,地图由4块256x256的瓦片组成;在二级,规模扩到 16块,以此类推。下图显示的是这种层次关系:
可以简单推算一下各级比例尺下,完整图幅的大小。栅格化后的坐标左上角是0,0,右下角是 size-1, size-1
级别 | 瓦片行/列数 | 图幅长/宽(size) | 粗略像素分辨率 |
0 | 1 | 256 | 156公里 |
1 | 2 | 512 | 78公里 |
2 | 4 | 1024 | 39公里 |
3 | 8 | 2048 | 19公里 |
4 | 16 | 4096 | 9公里 |
5 | 32 | 8192 | 5公里 |
6 | 64 | 16384 | 2.5公里 |
7 | 128 | 32768 | 1.3公里 |
8 | 256 | 65536 | 611米 |
9 | 512 | 131072 | 305米 |
10 | 1024 | 262144 | 152米 |
11 | 2048 | 524288 | 76米 |
12 | 4096 | 1048576 | 38米 |
13 | 8192 | 2097152 | 19米 |
14 | 16384 | 4194304 | 9米 |
15 | 32768 | 8388608 | 4.5米 |
16 | 65536 | 16777216 | 2.2米 |
17 | 131072 | 33554432 | 1.1米 |
18 | 262144 | 67108864 | 0.5米 |
这些瓦片被编号为行、列,加上比例尺,一个瓦片的索引即为 (level, x, y),即比例尺、所在列号、所在行号。我们只要这三个参数,即可从openstreetmap瓦片服务器上下载瓦片位图。
如:
http://c.tile.openstreetmap.org/0/0/0.png
http://c.tile.openstreetmap.org/2/2/1.png
需要注意的是,OSM瓦片服务器速度很慢,其中国的镜像位置有不少,比如
http://120.52.72.79/c.tile.openstreetmap.org/c3pr90ntcsf0/2/2/1.png
建议使用 FireFox 查看页面元素,获得使用的瓦片真实地址。
视图在这里可简单理解为一个窗口,具有有限的像素大小。视图控制包括显示、漫游、缩放等操作。这些操作的关键是从全局坐标(瓦片墨卡托地图)到视图坐标(一般左上角是0,0,右下角是 width-1,height-1) 的相互映射。
我们可以记录当前窗口左上角、右下角的全局坐标,从而实现窗口像素和全局像素的换算。然而,考虑到对于各个比例尺而言,图幅是不断变化的,且记录左上角、右下角坐标在比例尺变化后,对应的全局坐标必须刷新,我们决定不这么做。
可以采用更简单的方式——记录中心相对百分比坐标和当前比例尺来实现相同功能,进而,百分比作为第一种全局坐标系被建立起来,不妨称之为百分比坐标 。
百分比坐标是一个等效的尺度无关坐标,记录了当前视图中心位置对应的摩卡托坐标百分比。
//Center Lat,Lon double m_dCenterX; //percentage, -0.5~0.5 double m_dCenterY; //percentage, -0.5~0.5 int m_nLevel; //0-18
在第一章的投影坐标中,X.Y坐标定义域均为 [-piR , piR],而百分比坐标即为摩卡托坐标与2piR的比值,记录了当前中心实际偏离全图中心的的比例,实质是归一化。
设 px,py为百分比坐标, mx,my为摩卡托投影坐标,二者关系为
px = mx / 2piR
py = - my / 2piR
百分比坐标的好处是尺度无关。在各种比例尺下,一个固定的地理位置对应的百分比坐标不变。需要注意的是,百分比坐标的Y轴取反,以便在后续转换中与设备坐标在度量、坐标方向上取得一致。百分比坐标是一个浮点值,还无法对应到当前比例尺图幅上去。我们在第二章已经介绍了第二种全局坐标系,即全局像素坐标系。
全局像素坐标即当前比例尺下,一个位置对应的像素位置。第二章的表格里,记录了每个比例尺下的图幅大小。这个坐标就是地理位置对应当前比例尺图幅上的像素点位置。当前图幅左上角为(0,0),右下角为 (size-1, size-1)。 有了全局像素坐标,即可计算需要的像素位于哪个瓦片上。因为所有的瓦片都是256x256大小,瓦片位置直接等于 Xw / 256, Yw/256。同时,基于3.1, 3.2的工作,根据当前窗口的尺寸,即可立刻计算窗口中任意一点的全局像素坐标。代码是这样的:
计算窗口位置(dX,dY)对应的全局像素坐标(px,py)bool tilesviewer::CV_DP2World(qint32 dX, qint32 dY, double * px, double * py) { if (!px||!py) return false; //!1.Current World Pixel Size, connected to nLevel int nCurrImgSize = (1<<m_nLevel)*256; //!2.current DP according to center double dx = dX-(width()/2.0); double dy = dY-(height()/2.0); //!3.Percentage -0.5 ~ 0.5 coord double dImgX = dx/nCurrImgSize+m_dCenterX; double dImgY = dy/nCurrImgSize+m_dCenterY; //!4.Calculat the World pixel coordinats *px = dImgX * nCurrImgSize + nCurrImgSize/2; *py = dImgY * nCurrImgSize + nCurrImgSize/2; return true; }
上图中,黑色为全局像素坐标,红色为百分比坐标,绿色为窗口像素坐标。
全局像素是由瓦片拼接而成的,我们用3.2节的世界坐标系可方便求取瓦片像素坐标。
瓦片行 = wy /256, 瓦片列 = wx /256
瓦片像素: (wx % 256, wy %256)
上图中,蓝色为瓦片坐标与瓦片切割线,对应8x8,为比例尺 3 时的情形。
有了上述几种坐标系,我们可以为用户给定的 中心百分比坐标 m_dCenterX, m_dCenterY,结合窗口大小,直接获得需要的瓦片索引,以及他们粘贴在当前视窗上的像素偏移。
/*! \brief When the tileviewer enter its paint_event function, this callback will be called. \fn layer_tiles::cb_paintEvent \param pImage the In-mem image for paint . */ void layer_tiles::cb_paintEvent( QPainter * pPainter ) { if (!m_pViewer || m_bVisible==false) return; //!1,We should first calculate current windows' position, centerx,centery, in pixcel. double nCenter_X ,nCenter_Y; //!2,if the CV_PercentageToPixel returns true, painting will begin. if (true==m_pViewer->CV_Pct2World( m_pViewer->centerX(), m_pViewer->centerY(), &nCenter_X,&nCenter_Y)) { int sz_whole_idx = 1<<m_pViewer->level(); //!2.1 get current center tile idx, in tile count.(tile is 256x256) int nCenX = nCenter_X/256; int nCenY = nCenter_Y/256; //!2.2 calculate current left top tile idx int nCurrLeftX = floor((nCenter_X-m_pViewer->width()/2)/256.0); int nCurrTopY = floor((nCenter_Y-m_pViewer->height()/2)/256.0); //!2.3 calculate current right bottom idx int nCurrRightX = ceil((nCenter_X+m_pViewer->width()/2)/256.0); int nCurrBottomY = ceil((nCenter_Y+m_pViewer->height()/2)/256.0); //!2.4 a repeat from tileindx left to right. for (int col = nCurrLeftX;col<=nCurrRightX;col++) { //!2.4.1 a repeat from tileindx top to bottom. for (int row = nCurrTopY;row<=nCurrBottomY;row++) { 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; //!2.4.2 call getTileImage to query the image . if (true==this->getTileImage(m_pViewer->level(),req_col,req_row,image_source)) { //bitblt int nTileOffX = (col-nCenX)*256; int nTileOffY = (row-nCenY)*256; //0,0 lefttop offset int zero_offX = int(nCenter_X+0.5) % 256; int zero_offY = int(nCenter_Y+0.5) % 256; //bitblt cood int tar_x = m_pViewer->width()/2-zero_offX+nTileOffX; int tar_y = m_pViewer->height()/2-zero_offY+nTileOffY; //bitblt pPainter->drawImage(tar_x,tar_y,image_source); } } } } }
拖动、漫游对应的是鼠标消息。鼠标消息中的坐标全部都是视窗像素。我们只要把视窗像素换算为百分比,把音响施加到中心坐标下,即可完成动作。
缩放是指改变比例尺 m_nLevel,无需别的操作。m_nLevel改变后,立刻重绘窗口,一切皆自动计算——这得益于我们控制视图的参数是尺度无关的归一化坐标。
我们以拖动为例, 首先,在鼠标按键按下时,记录起始位置:
见bool layer_tiles::cb_mousePressEvent(QMouseEvent*event)
if (event->button()==Qt::LeftButton) { this->m_nStartPosX = event->pos().x(); this->m_nStartPosY = event->pos().y(); }
if (event->button()==Qt::LeftButton) { int nOffsetX = event->pos().x()-this->m_nStartPosX; int nOffsetY = event->pos().y()-this->m_nStartPosY; if (!(nOffsetX ==0 && nOffsetY==0)) { m_pViewer->DragView(nOffsetX,nOffsetY); this->m_nStartPosX = this->m_nStartPosY = -1; res = true; } }
void tilesviewer::DragView(int nOffsetX,int nOffsetY) { if (nOffsetX==0 && nOffsetY == 0) return; int sz_whole_idx = 1<<m_nLevel; int sz_whole_size = sz_whole_idx*256; double dx = nOffsetX*1.0/sz_whole_size; double dy = nOffsetY*1.0/sz_whole_size; this->m_dCenterX -= dx; this->m_dCenterY -= dy;
本章介绍了视图的控制。为了简单方便,我们建立了一个百分比坐标系,归一化的参数避免在缩放过程中修改视窗的全局坐标,且非常便于计算。当然,上述坐标系只是显示瓦片需要的坐标系。如果还要和经纬度打交道,那就必须引入经纬度坐标、墨卡托坐标。作为一个插件化的工程,我们希望这些坐标转化全部由主框架发布功能,供插件使用,在下一章节,我们就介绍基于Qt插件的图层架构设计。