手写地理信息组件系列 第11篇
MapControl的设计&优化
难度指数:★★★☆☆
感谢读者一直以来的陪伴,欢迎继续回到我们的GIS底层话题。
MapControl是什么
在GIS组件中,距离用户最近的图形控件,可以说非MapControl莫属了。
MapControl就是地图控件,是界面中地图数据的显示容器,提供对地图数据的各种可视操作。缩放、漫游、图形编辑等等都由MapControl作为入口执行。
GDI+ (Graphics Device Interface plus 图形设备接口),是一种处理Windows程序的图形输出技术。简单来说就是提供了一组绘图接口,图形绘制的实现交由系统及具体设备绘制输出。
GDI+ 在C#中体现为一组绘图函数。而地图的绘制也就基于这些基本的绘图函数,具体的方式,我们下边会有展开。
地图数据如何绘制
地图数据最初从shapefile中读取,主要是经过以下过程最终呈现出来的:
-
读取shp坐标点,获取地理坐标(经纬度)。
-
将地理坐标转换为投影坐标。也就是从球面坐标转换为平面坐标,这样才能在一个平面展示球面上的图形。
-
根据当前地图窗口的尺寸和地理范围,对投影坐标点做比例尺计算,形成窗口内可绘制的屏幕坐标。
-
将屏幕坐标传入控件自身提供的绘图函数,绘制出图。
过程总结起就是:
*地理坐标->投影坐标->屏幕坐标->绘图
这里的地理坐标不是必须的,有的图形存储投影坐标,所以无需转换。
地图绘制的概念调用链
软件的复杂性要求我们要对概念有明确的把握,以方便清晰的对功能进行分层。一个概念做一个概念的事,职责明确,依赖最小。
在图层的本质中,通过对Geometry、Feature的梳理,引出了独立的图层概念。
在之前的设计中,绘图函数被安排在各类型图形对象自身中,需要对绘图函数传入Graphics和Map对象。
对功能来说,绘制出图没有问题。问题在于:绘图函数的出现,污染了Geometry的纯粹性。导致任何需要Geometry的地方,都必须引入Graphics和Map这两个无关依赖,麻烦的很。
相对MapControl绘图来说,Geometry的层次属于的是最底层。底层不能依赖于高层,这是模块化设计的准则。
Geometry、Feature、Layer、Map、MapControl
那么,到底绘图这一应用应该出现在哪个概念中?
在这一概念链条中,毫无疑问哪个环节都能实现绘图,因为他们都能持有数据。问题是放哪里最合理?这一度让人陷入摇摆。
放在Geometry里,可以更容易的对每个图形的绘制风格做更细粒度的控制,代价是会对其概念造成一定侵入。
放在MapControl里,可以更方便对全局的图形绘制做统一管理。但是,对更多类型图形绘制的管理也会更复杂,每增加一种图层的类型,都需要对MapControl做出修改。依然得不偿失。
通过参阅各个商业软件的API和开源软件源码,发现在绘图这一问题上,都把图层(Layer)视为地图绘图的基本单元。
因为图层在意义上与一个shp文件相对应,管理着一组图形。图层可以直接的对这一组图形进行显示。典型的的例子如:对点图层进行符号化。根据城市点图形的人口属性,以不同的大小来绘制这组点图形,获得直观显示效果。
所以,绘图函数最合适的位置,应该是出现在Layer层面。下不入侵基本的图形和要素概念;同时,若有新增的图形或影像数据类型,就创建新的图层类型,并结合上层对象给予的"画板",构造自己的绘图逻辑,绘制到画板上就行了。
这样,就是做到对上层的影响最小,也是结构设计的水平可扩展。
概念实现
以下是Layer类在持有Feature集合的基础上,增加的绘图函数。目前只有矢量数据,所以只有点线面的绘图函数。若有影像、DEM或更多类型数据,就需要扩展Layer的类型,例如ImageLayer,TileLayer等等,只要新设计一个ILayer让他们继承就好。
public void Draw(Graphics g, Map map)
{
if (g == null || map == null)
return;
switch (shapeType)
{
case ShapeType.NullShape:
break;
case ShapeType.Point:
DrawPoint(g, map);
break;
case ShapeType.Line:
DrawLine(g, map);
break;
case ShapeType.Polygon:
DrawPolygon(g, map);
break;
default:
break;
}
}
//点绘图
public void DrawPoint(Graphics g, Map map)
{
foreach (var f in features)
{
//map对象通知停止渲染,即引发渲染中止异常
if (!map.IsRenderingEnabled)
throw new RenderingAbortedException("Point Drawing Aborted");
Vertex p = f.GetGeometry().Centroid;
System.Drawing.Point point = map.ToScreenPoint(p);
g.FillEllipse(new SolidBrush(Color.Orange),
new Rectangle(point.X, point.Y, 4, 4));
}
}
//线绘图
public void DrawLine(Graphics g, Map map)
{
foreach (var f in features)
{
if (!map.IsRenderingEnabled)
throw new RenderingAbortedException("Line Drawing Aborted");
Line line = f.GetGeometry() as Line;
System.Drawing.Point[] points =
new System.Drawing.Point[line.Vertexes.Count];
for (int i = 0; i < points.Length; i++)
{
//地理-屏幕坐标转换
points[i] = map.ToScreenPoint(line.Vertexes[i]);
}
g.DrawLines(new Pen(Color.WhiteSmoke, 2), points);
}
}
//面绘图
public void DrawPolygon(Graphics g, Map map)
{
foreach (var f in features)
{
if (!map.IsRenderingEnabled)
throw new RenderingAbortedException("Polygon Drawing Aborted");
Polygon polygon = f.GetGeometry() as Polygon;
System.Drawing.Point[] points =
new System.Drawing.Point[polygon.Vertexes.Count];
for (int i = 0; i < points.Length; i++)
{
points[i] = map.ToScreenPoint(polygon.Vertexes[i]);
}
g.FillPolygon(
new SolidBrush(Color.LightSeaGreen), points);
g.DrawLines(new Pen(Color.Brown, 1.5f), points);
}
}
地图类Map,除拥有对地图坐标和屏幕坐标转换的函数外,要负责对图层集合的绘图函数做调用
方法是遍历类内部持有的Layer集合,执行其Draw函数。并且其内部持有了两个变量isRendering和isRenderingEnabled。
第一变量指示当前绘图任务的执行状态;第二变量用于控制绘图是否允许进行,这里主要用来对绘图任务进行中止,在不需要的时候,及时退出逐层绘图这一相对耗时的过程。
// 正在渲染的标志变量
volatile bool isRendering = false;
// 允许渲染的标志变量
volatile bool isRenderingEnabled = true;
public bool IsRendering
{
get { return isRendering; }
}
public bool IsRenderingEnabled
{
get { return isRenderingEnabled; }
}
// 终止渲染
public void AbortRendering()
{
isRenderingEnabled = false;
}
/// <summary>
/// 地图渲染
/// </summary>
/// <param name="g">一个"画板"</param>
public void Render(Graphics g)
{
try
{
foreach (Layer lyr in layers)
{
isRendering = true;
lyr.Draw(g, this);
}
}
finally
{
isRenderingEnabled = true;
isRendering = false;
}
}
/// <summary>
/// 终止渲染引发的异常
/// </summary>
public class RenderingAbortedException : Exception
{
public RenderingAbortedException(String message)
: base(message)
{
}
}
MapControl的构造
MapControl首先是一个Control,在C#中通过继承UserControl类就能获得一个基本的可视控件。
布局很简单,控件本身+底部停靠的状态栏,状态栏左下角,被设计用来实时显示鼠标地理位置坐标。
谈到实时位置坐标显示,就必然离不开事件,继承UserControl的最大好处就是可以复用函数,只要在父类的鼠标移动处理函数之后,加入我们自己的坐标转换逻辑就能做到。下面来看:
//定义鼠标在地图上移动的事件
public event MouseMapMoveEventHandle MouseMapMove;
public delegate void MouseMapMoveEventHandle
(Object sender, MouseMapMoveEventArgs e);
//复写UserControl的OnMouseMove
protected override void OnMouseMove(MouseEventArgs e)
{
base.OnMouseMove(e);
this.OnMouseMapMove(e);
}
protected virtual void OnMouseMapMove(MouseEventArgs e)
{
if (_map != null && _map.Layers.Count > 0)
{
Vertex p = _map.ToMapVertex(
new System.Drawing.Point(e.X, e.Y));
this.locationLabel.Text =
String.Format("X:{0} Y:{1}",
Math.Round(p.x, 3), Math.Round(p.y, 3));
// 如果外部订阅了鼠标地理位置改变事件
// 每动一次鼠标就产生一个此事件
if (MouseMapMove != null)
MouseMapMove(this,
new MouseMapMoveEventArgs(p.x, p.y));
}
}
定义鼠标在地图控件移动时的事件参数:
public class MouseMapMoveEventArgs : EventArgs
{
double x;
double y;
public MouseMapMoveEventArgs(double x, double y)
{
this.x = x;
this.y = y;
}
public double X
{
get { return x; }
}
public double Y
{
get { return y; }
}
}
类似的,地图控件还应该有一个地图图形被选中的事件,典型的使用情况是点击图形显示属性,这一事件也是MapControl逃不掉的职责,这就来定义它:
//图形选中事件
public event GeometrySelectedEventHandle GeometrySelected;
public delegate void GeometrySelectedEventHandle
(Object sender, GeometrySelectedEventArgs e);
//选中事件肯定对应UserControl的点击事件
//复写之
protected override void OnMouseClick(MouseEventArgs e)
{
base.OnMouseClick(e);
if (GeometrySelected != null)
OnGeometrySelected(e);
}
//模拟图形对象选中事件
protected virtual void OnGeometrySelected(MouseEventArgs e)
{
if (_map != null && _map.Layers.Count > 0)
{
if (GeometrySelected != null)
{
Vertex p = _map.ToMapVertex(
new System.Drawing.Point(e.X, e.Y));
GeometrySelected(this,
new GeometrySelectedEventArgs((int)p.x));
}
}
}
图形对象选中事件参数,返回的是图形id:
public class GeometrySelectedEventArgs
{
int geoID;
public GeometrySelectedEventArgs(int geoID)
{
this.geoID = geoID;
}
public int GeoID
{
get { return this.geoID; }
}
}
图形对象选中事件是做的模拟处理,图形选中是一个相对复杂的过程,不是这篇的讨论范畴,我们就先按下不表。。
MapControl的核心逻辑
明人不说废话,MapControl核心逻辑就是绘图。
实现绘图的条件是MapControl持有一个Map对象,Map对象继续持有下层对象,逐层读取实现绘制。
流程是明确的,一个调用一个。但效率是一个很大问题。每缩放一次或者Resize,界面都要等到每个图层绘制完成才能响应,这是不能忍受的。
这里的解决方法是多线程延迟执行+旧线程中断。具体来看下边的解决方式。
public MapControl()
{
InitializeComponent();
this.DoubleBuffered = true;
this.BackgroundImageLayout = ImageLayout.None;
_map = new Map();
}
private Map _map;
public int MapWidth
{
get { return this.Width; }
}
public int MapHeight
{
get
{
return this.Height
- this.statusStrip.Height;
}
}
public Map Map
{
get { return _map; }
set { _map = value; }
}
volatile int reqTime = -1;
public void RenderMap(Extent extent)
{
reqTime = Environment.TickCount;
if (extent == null)
{
if (_map.Layers.Count < 1)
return;
_map.UpdateExtent(_map.Layers[0].Extent,
new Rectangle(0, 0, MapWidth, MapHeight));
extent = _map.Extent;
}
Thread t = new Thread(
new ParameterizedThreadStart(RenderMapThread));
//创建一个以当前时间命名的绘图线程
t.Priority = ThreadPriority.Highest;
t.Name = reqTime.ToString();
t.Start(extent);
}
public void RenderMap()
{
RenderMap(_map.Extent);
}
//延迟处理绘图请求
//如果短时间内有多次绘图请求
//只执行最新的一次请求
protected void RenderMapThread(Object extent)
{
Thread.Sleep(200);
if (int.Parse(Thread.CurrentThread.Name) != reqTime)
return;
RenderMapInternal((Extent)extent);
}
Object lockObj = new object();
private void RenderMapInternal(Extent extent)
{
try
{
//中止正在绘图的线程
if (_map.IsRendering)
_map.AbortRendering();
//当前线程独占式启动绘图
lock (lockObj)
{
//声明一个空白图片对象
Bitmap image = new Bitmap(MapWidth, MapHeight);
Graphics g = Graphics.FromImage(image);
g.SmoothingMode = SmoothingMode.HighQuality;
_map.UpdateExtent(extent,
new Rectangle(0, 0, MapWidth, MapHeight));
//将地图绘制到空白图片上
_map.Render(g);
g.Dispose();
//图片贴回控件
this.BackgroundImage = image;
// 如果绘图完成之后,发现窗口尺寸已经变化,
// 则按新尺寸再画一次
if (image.Width != MapWidth
|| image.Height != MapHeight)
{
RenderMapInternal(extent);
}
}
}
catch ()
{
}
}
调用层面测试
初步封装的MapControl现在可以使用了,它会出现在VS的工具箱中,可以拖拽到自己的界面上。
现在来看一下,要实现一次地图绘制,需要在客户调用层面做什么事情。
private void btn_OpenShp_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new OpenFileDialog();
ofd.Filter = "(*.shp)|*.shp";
if (ofd.ShowDialog() == DialogResult.OK)
{
ShapeFile shp = new ShapeFile();
Layer layer = shp.ReadShapeFile(ofd.FileName);
//MapControl加入图层
mapControl1.Map.Layers.Add(layer);
//渲染图层
this.mapControl1.RenderMap();
}
}
调用还是相对简单的。读取图层,加入图层,显示图层三步走。
这里是以上逻辑的显示效果:
在Form中订阅对象选定事件:
this.mapControl1.GeometrySelected += mapControl1_GeometrySelected;
void mapControl1_GeometrySelected(object sender,
GeometrySelectedEventArgs args)
{
MessageBox.Show(args.GeoID.ToString());
}
可以实现上文模拟的图形选择:
弹出数值是鼠标位置的地图x坐标,拿到了鼠标地图坐标,对象选择就不远了。
本篇完。