上一篇文章讲解如何加载各地图的WMS地图服务。虽然不涉及到瓦片,但是每次地图刷新都要请求网络,造成不小的网络负载。虽然判断视野是否改变确定是否请求网络来减小网络负载,但是这个方法仍然不理想。
谷歌的地图底图自带高程视觉,公路分级样式、行政区域分级样式、地图数据即时的更新速度等等优点,让人觉得有必要开发一个地图下载器。虽然谷歌本身被墙,但是谷歌地图还是可以访问的。地址如下:
http://www.google.cn/maps(可以手动输入:http://maps.google.cn)
谷歌已经关闭了开发者API,现在只能自己动手做一个了。下面仍然新建一个WinForm程序,增加对DotSpatial的引用,加入DotSpatial控件,代码如下:
using DotSpatial.Controls;
using DotSpatial.Data;
using DotSpatial.Projections;
using DotSpatial.Topology;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace GoogleWmts
{
public partial class MainForm : Form
{
private Map mapCtrl;
private ProjectionInfo currentProjection;
public const double WUHAN_WGS84_COORDINATE_Y = 30.883124;
public const double WUHAN_WGS84_COORDINATE_X = 114.419915;
private Coordinate wuhanCoordinate;
public Size lastSize;
public MainForm()
{
mapCtrl = new Map()
{
Left = 0,
Top = 0,
Size = new Size(0, 0),
Dock = DockStyle.Fill,
FunctionMode = FunctionMode.Pan
};
InitProjection();
InitializeComponent();
Controls.Add(mapCtrl);
}
private void InitProjection()
{
currentProjection = ProjectionInfo.FromEpsgCode(2432);
var xy = new double[2] { WUHAN_WGS84_COORDINATE_X, WUHAN_WGS84_COORDINATE_Y };
var z = new double[1];
Reproject.ReprojectPoints(xy, z, KnownCoordinateSystems.Geographic.World.WGS1984, currentProjection, 0, 1);
wuhanCoordinate = new Coordinate(xy);
}
}
}
很遗憾的是DotSpatial内建的坐标系统并不支持Google的900913坐标系,这里我使用Beijing1954坐标系,中央经线是105度,EPSG的CRSID是2432,预定义武汉的经纬度,用于设定地图初始化视野。currentProjection定义当前地图控件使用的坐标系。天地图、OSM地图、腾讯地图、谷歌的地图都是基于分辨率设定视野,因此新建一个ResolutionLayer类型的图层,以便通用,代码如下:
using DotSpatial.Controls;
using DotSpatial.Data;
using DotSpatial.Projections;
using DotSpatial.Symbology;
using DotSpatial.Topology;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace GoogleWmts
{
class ResolutionLayer : Layer, IMapLayer
{
public const double GOOGLE_ORIGIN_X = -20037508.3427892;
public const double GOOGLE_ORIGIN_Y = 20037508.3427892;
public const int TILE_WIDTH = 256;
public const int TILE_HEIGHT = 256;
public const int SCREEN_MILLIMETER_WIDTH = 564;
public const int SCREEN_PIXEL_WIDTH = 1600;
public const int MAX_ZOOM_LEVEL = 18;
private Dictionary resolutions;
string urlFormat = "http://mt2.google.cn/vt/lyrs=m@167000000&hl=zh-CN&gl=cn&x={0}&y={1}&z={2}&s=Galil";//X瓦图号,Y瓦片号,比例尺缩放层级
private Extent defaultExtent;
public int ZoomLevel { get; set; }
public Size WindowSize { get; set; }
public bool WindowCreated { get; set; }
public bool IsBusy { get; set; }
public ResolutionLayer()
{
defaultExtent = new Extent(-2000000000, -2000000000, 2000000000, 2000000000);
InitDirectory();
SetResolutionsByMath();
}
private void InitDirectory()
{
var rpt = @"Tiles\Google\";
if (!Directory.Exists(rpt))
Directory.CreateDirectory(rpt);
for (var i = 0; i <= MAX_ZOOM_LEVEL; i++)
{
var rp = rpt + @"\" + i;
if (!Directory.Exists(rp))
Directory.CreateDirectory(rp);
}
}
public override Extent Extent
{
get
{
return defaultExtent;
}
}
}
}
GOOGLE_ORIGIN_X与GOOGLE_ORIGIN_Y两个常量记录谷歌地图的坐标原点,TILE_WIDTH与TILE_HEIGHT两个常量记录单个瓦片图文件的像素大小。SCREEN_MILLIMETER_WIDTH常量记录当前显示屏幕的物理大小。
我当前的显示屏是19吋,物理大小是564毫米,请朋友在使用之前务必改成您自己的显示屏幕的物理大小。SCREEN_PIXEL_WIDTH常量记录的是当前显示屏的素大小。如果您不知道您当前显示设备的像素大小,请查看显示屏属性,找到当前设置的分辨率。
我当前的显示屏幕是1600像素的宽度,请朋友在使用之前务必改成你自己的屏幕的分辨率宽度。这里提供一个简单的方法获取屏幕物理大小与像素大小,代码如下:
[DllImport("gdi32.dll", EntryPoint = "GetDeviceCaps", CallingConvention = CallingConvention.Winapi)]
public static extern int GetDeviceCaps(IntPtr hdc, int code);
public const int HORZSIZE = 4;
var g = CreateGraphics();
var millimeterLength = NativeAPI.GetDeviceCaps(g.GetHdc(), NativeAPI.HORZSIZE);
var pixelLength = Screen.PrimaryScreen.Bounds.Width;
g.Dispose();
MAX_ZOOM_LEVEL是最大缩放级别,也就是街道级别。resolutions是各层级比例尺下的分辨率。defaultExtent是给DotSpatial计算图层最大视野用的。此变量必须给,否则看不到地图。这与DotSpatial计算视野,确定窗口更新区域的算法有关系。放在这里吧。ZoomLevel 是当前缩放级别,WindowSize记录窗体的实际大小,WindowCreated指示窗口是否已经创建成功。IsBusy指示图层当前是否正在下载瓦片图。如果正在下载中,那么不响应用户放大、缩小、移动等地图操作。InitDirectory方法设定瓦片的存储路径,组织方式是在当前软件的文件夹下新建一个Tiles文件夹,再新建一个Google文件夹,然然针对每一个比例尺新建文件夹,瓦片图文件名称以瓦片索引命名。
谷歌地图的分辨率可能通过计算的方法获取,代码如下:
private void SetResolutionsByMath()
{
resolutions = new Dictionary();
for (var i = 1; i <= MAX_ZOOM_LEVEL; i++)
resolutions.Add(i, 20037508.3427892 * 2 / 256 / Math.Pow(2, i));
}
上面说过,DotSpatial不支持Google的900913坐标系,那么必须进行坐标转换。在这里我使用Proj.4 C++库,并封装一个Win32动态库给C#调用,C++的调用Proj.4的代码如下:
typedef __declspec(dllexport) struct _COORDINATE
{
double x;
double y;
double z;
double m;
int srid;
}COORDINATE, *PCOORDINATE;
BRIDGE_API BOOL proj4_transform(PCSTR proj4_from, PCSTR proj4_to, COORDINATE* coordinate)
{
if (proj4_from == nullptr || strlen(proj4_from) < 5)
return FALSE;
if (proj4_to == nullptr || strlen(proj4_to) < 5)
return FALSE;
projPJ from = pj_init_plus(proj4_from);
projPJ to = pj_init_plus(proj4_to);
if (from == nullptr || to == nullptr)
return FALSE;
int code = pj_transform(from, to, 1, 1, &coordinate->x, &coordinate->y, &coordinate->z);
return !code;
}
C#调用代码如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
namespace GK.Collector.Server.Entity.DllImports
{
[StructLayout(LayoutKind.Explicit)]
public struct COORDINATE
{
[FieldOffset(0)]
public double x;
[FieldOffset(8)]
public double y;
[FieldOffset(16)]
public double z;
[FieldOffset(24)]
public double m;
[FieldOffset(32)]
int srid;
}
}
[DllImport("bridge.dll", EntryPoint = "proj4_transform", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
public static extern bool Proj4Transform(string proj4From, string projTo, IntPtr coordinate);
public static bool Transform(double[] xyz, string proj4From, string proj4To = "")
{
if (xyz.Length < 3)
return false;
if (string.IsNullOrWhiteSpace(proj4From))
return false;
if (string.IsNullOrWhiteSpace(proj4To))
proj4To = " +proj=longlat +datum=WGS84 +no_defs";
var c = new COORDINATE() { x = xyz[0], y = xyz[1], z = xyz[2] };
var ptr = Marshal.AllocHGlobal(Marshal.SizeOf(c));
Marshal.StructureToPtr(c, ptr, true);
Proj4Transform(proj4From, proj4To, ptr);
c = (COORDINATE)Marshal.PtrToStructure(ptr, typeof(COORDINATE));
Marshal.FreeHGlobal(ptr);
xyz[0] = c.x;
xyz[1] = c.y;
xyz[2] = c.z;
return true;
}
把常用的坐标系设定为字符串常量,以方便使用,代码如下:
public const string BJ2432_PROJ = "+proj=tmerc +lat_0=0 +lon_0=105 +k=1 +x_0=500000 +y_0=0 +ellps=krass +towgs84=15.8,-154.4,-82.3,0,0,0,0 +units=m +no_defs ";
public const string WORLD3857_PROJ = "+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs";
public const string GOOGLE_PROJ = "+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +no_defs ";
public const string WGS84_PROJ = "+proj=longlat +datum=WGS84 +no_defs ";
瓦片索引的计算是重中之重。其中包括瓦片对齐到用户窗口,避免地图移位、拖动不顺畅的问题,代码如下:
///
/// 获取瓦片图索引以及偏移
///
/// 原始坐标
/// 瓦片图索引
/// 偏移
private void GetTileIndexByCoordinate(double[] cxy, int[] txy, double[] rxy)
{
var z = new double[1];
var coef = resolutions[ZoomLevel] * TILE_WIDTH;
var wgs84Proj = KnownCoordinateSystems.Geographic.World.WGS1984;
//DotSpatial.Projections.Reproject.ReprojectPoints(cxy, z, Projection, wgs84Proj, 0, 1);
//cxy[0] = cxy[0] * 20037508.3427892 / 180;
//cxy[1] = Math.Log(Math.Tan((90 + cxy[1]) * Math.PI / 360)) / (Math.PI / 180);
//cxy[1] = cxy[1] * 20037508.3427892 / 180;
Transform(cxy, BJ2432_PROJ, GOOGLE_PROJ);
txy[0] = (int)((cxy[0] - GOOGLE_ORIGIN_X) / coef);
txy[1] = (int)((GOOGLE_ORIGIN_Y - cxy[1]) / coef);
rxy[0] = (cxy[0] - GOOGLE_ORIGIN_X) / coef - txy[0];
rxy[1] = (GOOGLE_ORIGIN_Y - cxy[1]) / coef - txy[1];
}
得到瓦片索引就可以下载了。用WebClient直接下载发现被谷歌屏蔽,通过Fiddler抓包工具发现可以顺利通过谷歌验证的HTTP包,代码如下:
private Image GetImageByWebClient(double tilex, double tiley)
{
var rp = @"Tiles\Google\" + ZoomLevel + @"\" + tilex + "_" + tiley + ".png";
if (File.Exists(rp))
{
var tb = Image.FromFile(rp);
//Console.WriteLine(rp);
return tb;
}
else
{
string url = string.Format(urlFormat, tilex, tiley, ZoomLevel);
//Console.WriteLine(url);
var downloader = new WebClient();
downloader.Headers.Add("Upgrade-Insecure-Requests: 1");
downloader.Headers.Add("User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36");
downloader.Headers.Add("Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
downloader.Headers.Add("Accept-Encoding: gzip, deflate, sdch");
downloader.Headers.Add("Accept-Language: zh-CN,zh;q=0.8");
try
{
var bts = downloader.DownloadData(url);
var str = new MemoryStream(bts);
var img = Image.FromStream(str);
img.Save(rp);
str.Close();
str.Close();
return img;
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
downloader.Dispose();
}
return null;
}
public void DrawRegions(MapArgs args, List regions)
{
if (!WindowCreated || WindowSize.Width <= 160 || WindowSize.Height <= 30)
return;
IsBusy = true;
var img = new Bitmap(args.ImageRectangle.Width, args.ImageRectangle.Height);
var g = Graphics.FromImage(img);
var resolution = resolutions[ZoomLevel];
var toffset = new double[2];
foreach (var region in regions)
{
var leftxy = new double[3] { region.MinX, region.MinY, 0 };
var txy = new int[2];
var rxy = new double[2];
toffset[0] = -1;
toffset[1] = -1;
GetTileIndexByCoordinate(leftxy, txy, rxy);
for (var i = region.MinX; i < region.MaxX; i += resolution * TILE_WIDTH)
{
for (var j = region.MinY; j < region.MaxY ; j += resolution * TILE_HEIGHT)
{
var tb = GetImageByWebClient(txy[0] + toffset[0], txy[1] + toffset[1]);
var tx = Convert.ToInt32((toffset[0] - rxy[0]) * TILE_WIDTH);
var ty = Convert.ToInt32((toffset[1] - rxy[1]) * TILE_HEIGHT);
g.DrawImage(tb, tx, ty);
toffset[1]++;
}
toffset[0]++;
toffset[1] = 0;
}
}
args.Device.DrawImage(img, 0, 0);
g.Dispose();
img.Dispose();
IsBusy = false;
}
下面两个方法用来计算有效的视野,给地图初始化之用,代码如下:
public Extent GetAvailableExtent(Coordinate center, Size rc)
{
var ext = new Extent();
var horizontal = resolutions[ZoomLevel] * rc.Width;
var vertical = resolutions[ZoomLevel] * rc.Height;
ext.MinX = center.X - horizontal / 2;
ext.MinY = center.Y - vertical / 2;
ext.MaxX = center.X + horizontal / 2;
ext.MaxY = center.Y + vertical / 2;
return ext;
}
public void GetDistance(double[] xy)
{
var resolution = resolutions[ZoomLevel];
xy[0] = xy[0] * resolution;
xy[1] = xy[1] * resolution;
}
至此瓦片图图层完成。
DotSpatial地图控件默认没有比例尺,也就是自由比例尺,可以无限制的缩放。而在线地图只有18个缩放级别,如果不用地图函数限制DotSpatial地图控件的行为,就会导致地图移位。代码如下:
using DotSpatial.Controls;
using DotSpatial.Topology;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace GoogleWmts
{
class TileMapFunction : MapFunction
{
private int zoomLevel = 11;
private System.Drawing.Point firstPoint;
private System.Drawing.Point lastPoint;
private ResolutionLayer layer;
public TileMapFunction(IMap mapCtrl, ResolutionLayer layer) :
base(mapCtrl)
{
this.layer = layer;
}
protected override void OnMouseDown(GeoMouseArgs e)
{
firstPoint = e.Location;
base.OnMouseDown(e);
}
protected override void OnMouseUp(GeoMouseArgs e)
{
lastPoint = e.Location;
//var offset = new double[2] { firstPoint.X - lastPoint.X, firstPoint.Y - lastPoint.Y };
//layer.GetDistance(offset);
//Map.ViewExtents.SetCenter(new Coordinate(Map.ViewExtents.Center.X + offset[0], Map.ViewExtents.Center.Y + offset[1]));
base.OnMouseUp(e);
}
protected override void OnMouseWheel(GeoMouseArgs e)
{
e.Handled = true;
if (layer.IsBusy)
return;
if (e.Delta > 0)
zoomLevel++;
else
zoomLevel--;
if (zoomLevel < 0)
zoomLevel = 0;
if (zoomLevel > ResolutionLayer.MAX_ZOOM_LEVEL)
zoomLevel = ResolutionLayer.MAX_ZOOM_LEVEL;
layer.ZoomLevel = zoomLevel;
Console.WriteLine("中心点:" + Map.ViewExtents.Center.X + "," + Map.ViewExtents.Center.Y);
//Map.ViewExtents = layer.GetAvailableExtent(Map.ViewExtents.Center, Map.ClientRectangle.Size);
base.OnMouseWheel(e);
}
protected override void OnMouseMove(GeoMouseArgs e)
{
e.Handled = true;
base.OnMouseMove(e);
}
}
}
首先声明瓦片图图层与地图函数对象,加入到地图控件,代码如下:
private ResolutionLayer layer;
private TileMapFunction func;
layer = new ResolutionLayer()
{
Projection = currentProjection,
WindowSize = mapCtrl.Size,
ZoomLevel = 10
};
func = new TileMapFunction(mapCtrl, layer);
mapCtrl.Layers.Add(layer);
mapCtrl.Projection = currentProjection;
mapCtrl.MapFunctions.Add(func);
mapCtrl.ActivateMapFunction(func);
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
layer.WindowCreated = true;
mapCtrl.ViewExtents = layer.GetAvailableExtent(wuhanCoordinate, layer.WindowSize);
}
处理窗品大小改变事件,使地图始终铺满窗口,代码如下:
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
if(lastSize!=Size)
{
lastSize = Size;
if (WindowState != FormWindowState.Minimized && mapCtrl.Width <= 0)
{
layer.WindowSize = this.Size;
mapCtrl.Size = this.Size;
Console.WriteLine("中心点:" + mapCtrl.ViewExtents.Center.X + "," + mapCtrl.ViewExtents.Center.Y);
}
}
}