使用 .NET 编写 GIS 和地图软件






4.89/5 (179投票s)
在本系列的第三部分中,.NET 的“GIS.NET”地图组件的作者们解释了如何编写一个可以显示地理坐标的地理地图引擎。提供了 C# 和 VB.NET 的源代码,可以平移和缩放一个示例地理对象(内布拉斯加州)。
引言
在本系列的第一部分中,我描述了如何编写一个用于原始 GPS NMEA 数据的解释器。第二部分描述了如何监控和强制执行 GPS 精度数据以开发商业级软件。这些文章包括 C# 和 VB.NET 的源代码,它们利用 GPS 卫星的力量来确定当前位置、将计算机时钟同步到原子时间,并在阴天时指向卫星。然而,即使有了所有这些代码,大多数开发人员仍然需要一种方法来显示 GPS 信息以及其他地理特征。在我的同事 Phil Smith 的帮助下,他是我们“GIS.NET”地图组件和“Geodesy.NET”坐标和投影库的首席开发者,本文将教你如何生成自己的地图。
三三法则
要理解地图技术的原理,必须对三个坐标系统有扎实的理解:地理坐标、投影坐标和像素坐标。每个系统在显示地图时都扮演着重要角色,从一个系统到另一个系统的转换至关重要。开发人员通常从一个地理坐标(以经纬度表示)开始。然后,它从地球的扁球体(大致是球形)形状转换为一个平面,从而得到一个投影坐标:一个真正的二维平面坐标。投影坐标是一对东向/北向值,描述了相对于“中央子午线”(经线)的东向距离和相对于“中央纬线”(纬线)的北向距离。将地理坐标转换为投影东向/北向坐标以及反向转换的方法称为投影。最后,投影坐标被翻译和缩放,以便它们在屏幕上的特定位置对齐,从而得到像素坐标。像素坐标与您用来在窗体上对齐控件的坐标相同。
让我们仔细看看这三个坐标系统以及如何在它们之间进行转换以生成地图。
地球最好是平的
在本文章的第一部分和第二部分中,我们讨论了 GPS 设备如何将您的位置报告为经纬度。这些对通常被称为“地理坐标”。然而,地理坐标的问题在于它们代表地球表面的坐标,而地球表面是扁球体(大致是球形)形状。由于我们的计算机显示器是平的,我们需要一种方法在尝试显示它之前将地球“展开”成一个完全平坦的形状。这种技术被称为投影,它对于显示地图至关重要。如今,世界各地有超过一百种地图投影在使用,每种投影都有特定的用途。例如,墨卡托投影被船只广泛使用,因为它生成的地图中恒定方位线是一条直线,大大简化了导航。然而,这种投影的一个副作用是,当您靠近南北极时,它会扭曲所有物体的大小,因此这种投影不适用于其他目的。
正如您所见,地图投影可以使相同的地理特征以完全不同的尺寸和形状出现,但每种都是完全有效的。事实上,一些投影(如“正交”投影)是平面的,但在平面显示器上产生 3D 图像的错觉。这种投影是一个当代示例,被许多 3D 应用程序广泛使用,包括现代 3D 游戏引擎。无论形状和大小如何,投影坐标都可以展平 3D 坐标,这极大地简化了地图绘制任务。
科学来了
地图投影背后的数学可能有点令人畏惧。例如,“范德格林滕”投影使用以下公式(其中 A、G、P 和 Q 是映射参数)
对于本文,我们将编写代码来实现一个更简单的投影,称为“等距圆柱投影”或“Plate Carée”,因为它简单且速度快,是我们 GIS.NET 3.0 组件中使用的默认投影,该组件包含二十五种其他投影的库。
当地理坐标以弧度表示时,地图投影的公式更容易处理。弧度计算起来很直接,并且可以应用于纬度或经度。
// 90° or ninety degrees
double degrees = 90.0;
// Convert to radians
double radians = degrees * (Math.Pi / 180.0);
// Convert radians back to degrees
degrees = radians / (Math.PI / 180.0);
所有地图投影源代码都分为两种方法。第一种方法称为正向投影,它将地理坐标转换为投影坐标。第二种方法正好相反,它将投影坐标转换回地理坐标。这称为反向投影或解投影。这是我们示例 Plate Carée 投影的两种方法的外观:
using System.Drawing;
public class PlateCaree
{
public PointF Project(PointF geographicCoordinate)
{
// First, convert the geographic coordinate to radians
double radianX = geographicCoordinate.X * (Math.PI / 180);
double radianY = geographicCoordinate.Y * (Math.PI / 180);
// Make a new Point object
PointF result = new PointF();
// Calculate the projected X coordinate
result.X = (float)(radianX * Math.Cos(0));
// Calculate the projected Y coordinate
result.Y = (float)radianY;
// Return the result
return result;
}
public PointF Deproject(PointF projectedCoordinate)
{
// Make a new point to store the result
PointF result = new PointF();
// Calculate the geographic X coordinate (longitude)
result.X = (float)(projectedCoordinate.X / Math.Cos(0) /
(Math.PI / 180.0));
// Calculate the geographic Y coordinate (latitude)
result.Y = (float)(projectedCoordinate.Y / (Math.PI / 180.0));
return result;
}
}
使用这个类,我们现在可以生成地球上任何位置的投影坐标。
// Define a geographic coordinate, in this case a GPS location
PointF myLocation = new PointF();
myLocation.Y = 39.0; // 39 North
myLocation.X = -105.0; // 105 West
// Now use our projection class to flatten this coordinate
PlateCaree projection = new PlateCaree();
PointF myProjectedLocation = projection.Project(myLocation);
myLocation = projection.Deproject(myProjectedLocation);
…然后对每个地理坐标重复此过程,直到所有数据都可以表示为投影坐标。一旦完成,只剩下最后一步:将这些坐标转换为可以绘制在屏幕上的像素坐标。
绘制地球
如果我们正在创建一个在墙上显示的地图,我们的任务将很简单,因为我们可以一次绘制所有数据即可完成。然而,地图软件应该允许用户平移和缩放地图,以便他们可以更详细地探索地图的任何部分。为此,我们必须设想一个矩形(在 GIS.NET 3.0 中称为“视口”),它代表我们实际想看到的地图部分。一旦已知,数学将被第三次应用,以将投影坐标转换为像素坐标。换句话说,我们必须使我们的视口左上角与我们Form
中的 (0,0) 对齐。
.NET 开发人员已经熟悉像素坐标。这些坐标与您用于将控件放置在Form
上的坐标相同,因此无需进一步解释。但是,我们需要一种方法来将投影坐标转换为像素坐标。为此,必须缩放和平移投影坐标,以使视口与Form
的像素大小对齐。平移是通过应用视口左上角 X 坐标的负值,然后是 Y 坐标来执行的。水平缩放是通过将要绘制区域的像素宽度除以视口的投影宽度来计算的,垂直缩放类似。
幸运的是,在桌面上,我们可以利用Matrix
类来完成这项任务的繁重工作。Matrix
对象可以以PointF
数组的形式旋转、平移和缩放坐标数组。生成的代码看起来像这样:
// Use a matrix for translation and scaling
Matrix transform = new Matrix();
// First, translate all projected points so that they match up with pixel 0,0
transform.Translate(-viewport.X, viewport.Y, MatrixOrder.Append);
// Next, scale all points so that the viewport fits inside the form.
transform.Scale(this.Width / viewport.Width,
this.Height / -viewport.Height, MatrixOrder.Append);
您可能已经注意到,在垂直缩放中使用了负号。这是因为投影坐标系统的 Y 轴与像素坐标系统的 Y 轴相反。换句话说,在投影坐标中,Y 值越大表示向上,而在像素坐标中,Y 值越大表示向下。这里的负号可以防止图像颠倒显示。
由于我们在此示例中使用 GDI+,所有绘图都使用Graphics
类完成,通常在OnPaint
方法中。幸运的是,我们可以通过将Graphics
对象的Transform
属性设置为我们的Matrix
来轻松应用我们的转换和缩放。有了这个,我们现在可以使用投影坐标调用DrawLine
等绘图方法!结果,绘制对象变得相当简单。
protected override void OnPaint(PaintEventArgs e)
{
// Apply this transform to all graphics operations
e.Graphics.Transform = transform;
// Now draw nebraska using a green interior and black outline
e.Graphics.FillPolygon(Brushes.Green, projectedCoordinates);
e.Graphics.DrawPolygon(Pens.Black, projectedCoordinates);
}
保持良好的宽高比
在本文中,我们处理两个矩形:“视口”,一个要绘制的投影区域,以及Form
本身,所有内容都将在其中显示。然而,如果视口的形状与Form
的形状差异很大,可能会发生失真(参见下方的“之前”图片)。要解决这个问题,我们必须使视口的形状与Form
的形状匹配。这是通过调整视口的“宽高比”来实现的。
宽高比是通过将矩形的宽度除以其高度来计算的。例如,如果一个矩形的宽度是十个像素,高度是二十个像素,那么宽高比就是 0.5。要调整视口的宽高比,将其宽高比与矩形Form
本身的宽高比进行比较。如果视口的宽高比大于Form
的宽高比,则会增加视口的高度。否则,会增加视口的宽度。生成的代码如下所示:
// Calculate the aspect ratio of the Form
float pixelAspectRatio = (float)this.Width / this.Height;
// Calculate the aspect ratio of the viewport
float projectedAspectRatio = viewport.Width / viewport.Height;
// Make a copy of the viewport that we can change
RectangleF adjustedViewport = viewport;
// Is the Form's aspect ratio larger than the viewport's ratio?
if (pixelAspectRatio > projectedAspectRatio)
{
// Yes. Increase the width of the viewport
adjustedViewport.Inflate(
(pixelAspectRatio * adjustedViewport.Height - adjustedViewport.Width)
/ 2,
0);
}
// Is the viewport's aspect ratio larger than the form's ratio?
else if (pixelAspectRatio < projectedAspectRatio)
{
// Yes. Increase the height of the viewport
adjustedViewport.Inflate(
0,
(adjustedViewport.Width / pixelAspectRatio - adjustedViewport.Height)
/ 2);
}
…调整宽高比后,所有绘制的地理对象现在都能保持其形状,即使Form
的形状发生变化。
导航地图
现在我们能够绘制地图的一部分了,本例中的最后一步是实现某种形式的导航。平移地图意味着在不改变其大小的情况下移动视口。然而,缩放有点违反直觉:要将地图放大,您必须使投影视口变小。视口越小,应用的比例因子越大。
如果我们使用PointF
类来表示投影坐标,我们可以使用RectangleF
类来表示投影视口。缩放变成了调用Inflate
方法来缩小或增大投影视口以分别进行放大或缩小。另一点重要的是要提到“按百分比缩放”的概念。缩放应始终使用当前视口的百分比进行。否则,当您放大越多时,缩放会显得夸张,而当您缩小越多时,则几乎没有效果。
public void ZoomIn()
{
// First, get the width of the viewport, then calculate 10% of it
float zoomWidthAmount = -viewport.Width * 0.10f;
float zoomHeightAmount = -viewport.Height * 0.10f;
// Next, apply the amounts to shrink (yes, shrink) the viewport to zoom in
viewport.Inflate(zoomWidthAmount, zoomHeightAmount);
// And repaint
Invalidate();
}
public void ZoomOut()
{
// First, get the width of the viewport, then calculate 10% of it
float zoomWidthAmount = viewport.Width * 0.10f;
float zoomHeightAmount = viewport.Height * 0.10f;
// Next, apply the amounts to shrink (yes, shrink) the viewport to zoom in
viewport.Inflate(zoomWidthAmount, zoomHeightAmount);
// And repaint
Invalidate();
}
开发人员可能会认识到,这些方法可以多么容易地插入到Form
的MouseWheel
事件中。平移方法同样直接,但涉及使用Offset
方法。
public void PanUp()
{
// First, get the height of the viewport, then calculate 10% of it
float zoomHeightAmount = -viewport.Height * 0.10f;
// Shift the viewport by this amount
viewport.Offset(0.0f, zoomHeightAmount);
}
public void PanDown()
{
// First, get the height of the viewport, then calculate 10% of it
float zoomHeightAmount = viewport.Height * 0.10f;
// Shift the viewport by this amount
viewport.Offset(0.0f, zoomHeightAmount);
}
…再次,您可能已经认识到如何将这些方法插入KeyDown
事件。有了这些方法,您现在就可以以任何缩放级别探索您的地图。如果您熟悉本文的第一部分和第二部分,您正朝着开发一个能够绘制您的 GPS 位置以及各种地理特征的商业应用程序迈进。无论您的目的是绘制点、线还是多边形,方法都是相同的。
反向播放
此时,我们已经成功绘制了地图并提供了平移和缩放的方法。但是,能够查看鼠标指针的位置(以地理或投影坐标而不是像素坐标表示)会很有帮助。因此,我们将一些代码添加到Form
的MouseMove
事件中,它将显示鼠标在所有三个坐标系统中的位置。
转换始于像素坐标,然后使用本文前面设置的Matrix
的逆转换到投影坐标。最后,使用我们投影的Deproject
方法将投影坐标转换回其地理等效值。代码如下所示:
protected override void OnMouseMove(MouseEventArgs e)
{
/* When the mouse is moved over the map, show the coordinate the mouse
* is hovering over, in all three coordinate systems: pixel, projected,
* and geographic.
*/
/* We use a Matrix to convert from projected coordinates to pixel
* coordinates. The *inverse* of this matrix is used to convert pixels
* back into projected coordinates.
*/
// First, make a copy of the current transform
Matrix reverseTransform = transform.Clone();
// Next, invert the transform
reverseTransform.Invert();
// We can now use this to calculate a projected coordinate.
Point[] projectedCoordinate = new Point[] { e.Location };
reverseTransform.TransformPoints(projectedCoordinate);
/* "Points" now contains projected coordinates. Use our projection to
* convert this into geographic (latitude/longitude) coordinates.
*/
PointF geographicCoordinate = plateCaree.Deproject(projectedCoordinate[0]);
// Finally, display all three coordinates: pixel, projected, geographic
Console.WriteLine("Pixel: " + e.Location.ToString());
Console.WriteLine("Projected: "
+ projectedCoordinate[0].ToString();
Console.WriteLine("Geographic: "
+ geographicCoordinate.ToString();
}
…有了这段代码,您现在就可以在两个方向上自由地在所有三个坐标系统之间进行转换。
结论
在屏幕上显示地理数据的任务涉及将数据转换为另外两个坐标系统。地图投影用于将 3D 坐标展平为 2D 坐标,然后使用矩阵数学以有意义的方式绘制地理数据。平移和缩放地图涉及更改视口的位置和大小,导航通常与键盘和鼠标事件相关联。视口的宽高比被调整为与Form
的宽高比匹配,以防止失真。最后,使用Matrix
的逆将坐标从像素转换为投影,然后使用投影的Deproject
方法将投影坐标转换回其地理等效值。
为了开发商业质量的地图应用程序,我们还有许多主题需要涵盖。地理数据源、空间索引、矢量归一化和绘图优化等主题很容易占据几篇文章。.NET 的许多商业组件解决了这些主题。但是,本文至少可以帮助您对如何在自己的 .NET 应用程序中显示地理数据获得扎实的理解。
请通过评分这篇文章来表明您对第四部分的兴趣。