上一篇文章讲解如何加载各地图的WMS地图服务。虽然不涉及到瓦片,但是每次地图刷新都要请求网络,造成不小的网络负载。虽然判断视野是否改变确定是否请求网络来减小网络负载,但是这个方法仍然不理想。
谷歌的地图底图自带高程视觉,公路分级样式、行政区域分级样式、地图数据即时的更新速度等等优点,让人觉得有必要开发一个地图下载器。虽然谷歌本身被墙,但是谷歌地图还是可以访问的。地址如下:
http://www.google.cn/maps(可以手动输入:http://maps.google.cn)
一、新建WinForm项目
谷歌已经关闭了开发者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<int, double> resolutions; string urlFormat = "http://mt2.google.cn/vt/[email protected]&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<int, double>(); 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 [email protected] +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 [email protected] +no_defs "; public const string WGS84_PROJ = "+proj=longlat +datum=WGS84 +no_defs ";
三、计算瓦片索引并下载
瓦片索引的计算是重中之重。其中包括瓦片对齐到用户窗口,避免地图移位、拖动不顺畅的问题,代码如下:
/// <summary> /// 获取瓦片图索引以及偏移 /// </summary> /// <param name="cxy">原始坐标</param> /// <param name="txy">瓦片图索引</param> /// <param name="rxy">偏移</param> 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<DotSpatial.Data.Extent> 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); } } }
六、完善WinForm窗口事件
首先声明瓦片图图层与地图函数对象,加入到地图控件,代码如下:
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); } } }