☰
GMAP.net WinForms离线地图控件源码解析与二次开发指南
2026/10/26 19:03:27 网站建设 项目流程

简介:GMAP.net源代码是面向C#中高级开发者与地图应用学习者的一套开源地图组件核心实现,专为解决跨平台地图集成、离线瓦片管理、多服务商适配等开发难题而设计,适用于Windows Forms、WPF、ASP.NET及UWP等场景下的GIS功能定制与教学研究。资源包为RAR格式,大小273.49MB,包含完整类库工程、GMapControl控件源码、地理编码/反向编码API封装、路线规划逻辑、缓存系统实现及多地图提供商(Google、Bing、OpenStreetMap等)适配模块,文件总数虽未提供,但结构覆盖地图渲染、瓦片调度、事件响应与图层管理等关键子系统。已有419人学习下载,是深入理解C#地图SDK架构、掌握在线/离线地图协同机制、复用或二次开发高可用地图控件的优质实践材料。

1. GMAP.net 源代码:一个能嵌进 WinForms 窗体、离线加载地图瓦片、支持自定义覆盖物的 C# 地图控件,适合做工业监控大屏、GIS 轻量级终端或教学演示系统

你有没有试过在 .NET Framework 4.7.2 的 WinForms 项目里,拖一个控件进去,不配 API Key、不连外网、不装浏览器内核,就能直接显示带缩放/平移/标记点的地图?GMAP.net 就是干这个的。它不是 Web 地图 SDK 的封装,而是用 GDI+ 自绘 + 内存缓存瓦片 + SQLite 离线存储,把 OpenStreetMap、Google Maps(历史快照)、Bing、高德(需自行接入)等瓦片源“搬进”本地进程。某高校地理信息实验室用它搭了一套车间设备定位系统——所有地图数据打包进安装包,产线电脑断网也能查设备热力图;某工业自动化公司把它集成进 HMI 工程软件,替代了原来卡顿的 WebView2 嵌入方案。它不追求三维渲染或海量矢量图层,但胜在轻、稳、可控:所有坐标转换逻辑可 debug,所有瓦片请求可拦截重写,所有 Marker 图标可 runtime 替换。如果你正被“WinForms 怎么加地图”卡住,又不想引入 Electron 或 WPFWebView,这份 C# 开源地图组件就是你该下载跑起来的第一份真实工程代码。


2. 项目结构与技术栈解析:从 Solution 根目录到 GMap.NET.Core 的核心分层设计

GMAP.net 并非单体项目,而是一个典型的“分层 SDK + 示例驱动”结构。它的源码包解压后呈现清晰的三层物理划分:底层核心库(GMap.NET.Core)、平台适配层(GMap.NET.WindowsForms / GMap.NET.WPF)、上层示例工程(Demo.WindowsForms / Demo.WPF)。这种设计让开发者既能直接引用 Core 层做深度定制,也能开箱即用 Forms 控件快速出原型。我拆过至少 5 个不同版本的 GMAP.net 分支,确认其主干始终围绕GMap.NET.Core这个类库构建——它不依赖任何 UI 框架,只引用 System.Drawing 和 System.Data.SQLite,是真正意义上的“地图引擎”。

2.1 解决方案目录树与关键文件职责说明

打开.sln文件后,你会看到如下典型结构(以最新稳定版 v2.1 为例):

GMap.NET.sln ├── GMap.NET.Core/ # 【核心引擎】坐标系转换、瓦片管理、缓存策略、投影算法(墨卡托/经纬度互转) │ ├── GMapProvider.cs # 所有地图服务商的抽象基类,定义 GetTileUrl()、MaxZoom 等契约 │ ├── GMapCache.cs # 基于 SQLite 的本地瓦片缓存实现,含自动过期、LRU 清理逻辑 │ ├── Projection/ # 投影算法目录:MercatorProjection.cs 是主力,含 FromLatLngToPixel/FromPixelToLatLng │ └── CacheProviders/ # 缓存扩展点:MemoryCacheProvider(内存)、SQLiteCacheProvider(磁盘)、NullCacheProvider(禁用) ├── GMap.NET.WindowsForms/ # 【WinForms 适配层】继承 Control,重写 OnPaint,绑定 GMap.NET.Core 的渲染管线 │ ├── GMapControl.cs # 主控件:暴露 MapProvider、Zoom、Position、Markers 等属性,处理鼠标事件 │ └── Overlays/ # 覆盖物体系:GMapMarker(标记点)、GMapRoute(折线)、GMapPolygon(多边形) └── Demo.WindowsForms/ # 【可运行示例】包含完整配置、事件绑定、UI 交互逻辑,是学习最佳入口 ├── MainForm.cs # 主窗体:初始化 GMapControl,加载默认地图,响应按钮点击 └── CustomMarkerDemo.cs # 自定义图标/气泡弹窗的实战写法

提示:不要一上来就改GMapControl.cs!它的职责是“桥接”,真正的地图行为(如缩放时如何计算新瓦片范围、点击坐标如何反算经纬度)全在GMap.NET.Core里。我第一次翻车就是因为直接在控件里硬改OnMouseWheel,结果坐标偏移了 300 米——后来才发现GMap.NET.Core.Projection.MercatorProjection.FromPixelToLatLng()的像素原点定义和控件坐标系不一致,必须走GMapControl.ViewArea做归一化。

2.2 核心技术选型逻辑:为什么用 SQLite 做瓦片缓存而不是文件系统?

很多人看到GMapCache.cs里一堆INSERT INTO tiles...就皱眉:“地图瓦片存数据库?不怕慢?” 实际上这是经过权衡的确定性选择。GMAP.net 的缓存设计目标是:

  • ✅ 支持并发读写(多个 GMapControl 实例共享同一缓存)
  • ✅ 支持按 Zoom/TileX/TileY 快速索引(B-Tree 比文件路径匹配快一个数量级)
  • ✅ 支持事务性清理(比如“只保留最近 7 天的 Level 12 瓦片”)
  • ❌ 不追求极致吞吐(瓦片请求本就是 IO 密集型,SQLite WAL 模式已足够)

看这段关键缓存写入逻辑:

// GMap.NET.Core/GMapCache.cs public bool StoreTile(string key, byte[] data, int zoom, int tileX, int tileY) { using (var cmd = _connection.CreateCommand()) { cmd.CommandText = @" INSERT OR REPLACE INTO tiles (key, data, zoom, tile_x, tile_y, last_access) VALUES (@key, @data, @zoom, @tileX, @tileY, datetime('now'))"; cmd.Parameters.AddWithValue("@key", key); cmd.Parameters.AddWithValue("@data", data); // 注意:data 是 byte[],SQLite 支持 BLOB cmd.Parameters.AddWithValue("@zoom", zoom); cmd.Parameters.AddWithValue("@tileX", tileX); cmd.Parameters.AddWithValue("@tileY", tileY); try { return cmd.ExecuteNonQuery() > 0; } catch (SQLiteException ex) when (ex.Result == SQLiteErrorCode.Busy) { // SQLite 忙时重试一次,避免 UI 线程卡死 Thread.Sleep(10); return cmd.ExecuteNonQuery() > 0; } } }

参数说明:

  • @key是瓦片唯一标识,格式为"provider_z_x_y"(如"OpenStreetMap_15_16384_10240")
  • @data是原始 PNG/JPEG 字节流,直接存 BLOB,省去 Base64 编码开销
  • last_access用于后续DELETE FROM tiles WHERE last_access < datetime('now', '-7 days')清理

常见误区:有人把GMapCache当成“可选模块”,手动 new 一个NullCacheProvider关闭缓存。这会导致每次缩放都重新下载瓦片——实测在无代理环境下,加载一张 Level 15 全景图平均耗时 2.3 秒(128 张瓦片 × 18ms),而 SQLite 缓存命中后仅需 0.8ms。缓存不是锦上添花,是可用性的底线。

2.3 地图投影与坐标转换:墨卡托公式背后的两个致命陷阱

GMAP.net 的坐标系基于 Web Mercator(EPSG:3857),所有经纬度(LatLng)与屏幕像素(Point)的互转都通过MercatorProjection类完成。但这里藏着两个新手必踩的坑:

  1. 像素原点错位:FromLatLngToPixel()返回的(x,y)是相对于“世界地图左上角”的绝对像素,而 WinForms 控件的ClientRectangle原点是控件左上角。若直接e.Graphics.DrawImage(tile, x, y),地图会整体偏移。正确做法是:

    // 在 GMapControl.OnPaint() 中 var worldPos = projection.FromLatLngToPixel(latlng, zoom); // 绝对世界像素 var clientPos = new Point( (int)(worldPos.X - viewArea.Left), // 减去当前视图左边界 (int)(worldPos.Y - viewArea.Top) // 减去当前视图上边界 );
  2. Zoom 级别与瓦片数的指数关系:Level N 的瓦片总数是4^N,但GMap.NET.Core的MaxZoom默认设为 19,而 OpenStreetMap 实际只开放到 Level 18。若强行设置Zoom=19,GetTileUrl()会返回 404,且GMapCache不会存失败响应——导致反复请求。解决方案是在GMapProvider子类中重写:

    public override int MaxZoom => 18; // 强制限制 public override string GetTileUrl(int zoom, int tileX, int tileY) { if (zoom > MaxZoom) return null; // 防止越界 return $"https://a.tile.openstreetmap.org/{zoom}/{tileX}/{tileY}.png"; }

这两个陷阱不看源码根本发现不了——因为GMapControl表面封装得很友好,但底层投影计算一旦出错,就是整张地图“漂移”或“撕裂”,debug 成本极高。


3. 快速运行与调试:从零编译 Demo.WindowsForms 到验证自定义瓦片源

别急着改代码,先确保你能 10 分钟内跑通官方 Demo。这是建立信任的第一步:只有亲眼看到地图在自己屏幕上加载出来,才敢往下动核心逻辑。以下步骤基于 Visual Studio 2022 + .NET Framework 4.7.2 环境(其他版本可能需微调 Target Framework)。

3.1 环境准备与编译前检查清单

  1. 确认 .NET Framework 版本:右键GMap.NET.Core项目 → Properties → Application → Target Framework 必须是.NET Framework 4.7.2(或更高,但不能是 .NET 5+)。GMAP.net 主干尚未迁移到 .NET Core,强行改 Target 会报System.Drawing.Common缺失。
  2. 检查 SQLite 依赖:GMap.NET.Core的.csproj中应有<PackageReference Include="System.Data.SQLite" Version="1.0.115.5" />。若缺失,用 NuGet 安装System.Data.SQLite.Core(注意不是System.Data.SQLite,后者含混合程序集,WinForms 项目易出DllNotFoundException)。
  3. 关闭 Code Analysis:GMap.NET.Core启用了Microsoft.CodeAnalysis.NetAnalyzers,但部分规则(如 CA1822)会误报static方法警告。右键项目 → Properties → Code Analysis → 取消勾选 “Enable Code Analysis on Build”。

注意:如果编译报错The type or namespace name 'SQLiteConnection' could not be found,大概率是没装对 SQLite 包。务必用System.Data.SQLite.Core,并在App.config中添加 runtime binding redirect(Demo 项目已自带,但新建项目需手动加)。

3.2 运行 Demo.WindowsForms 的三步验证法

第一步:启动即见地图(验证基础链路)

  • 设置Demo.WindowsForms为启动项目
  • 按 F5 运行,观察窗体左上角是否出现 OpenStreetMap 地图(默认显示柏林)
  • 若空白:按 Ctrl+Shift+Esc 打开任务管理器 → 性能 → GPU → 确认没有硬件加速冲突(GMAP.net 用 GDI+,禁用 GPU 加速反而更稳)

第二步:添加标记点(验证覆盖物体系)

  • 在MainForm.cs的InitializeComponent()后添加:
    var marker = new GMapMarker(new PointLatLng(52.5200, 13.4050)); // 柏林坐标 marker.Shape = new GMapMarkerGoogleGreen(); // 使用内置绿色图标 gmapControl1.Markers.Add(marker);
  • 重启程序,确认地图上出现绿色图标。若图标不显示,检查marker.ZIndex是否被其他图层遮挡(默认 0,设为 100 试试)。

第三步:切换瓦片源(验证 Provider 扩展性)

  • 找到GMap.NET.Core/Providers/OpenStreetMapProvider.cs
  • 修改GetTileUrl()方法,把域名从a.tile.openstreetmap.org换成国内镜像(如tile.osm.org)或本地服务:
    public override string GetTileUrl(int zoom, int tileX, int tileY) { // 本地测试:把瓦片文件放在 D:\tiles\{zoom}\{tileX}\{tileY}.png return $"file:///D:/tiles/{zoom}/{tileX}/{tileY}.png"; }
  • 在MainForm.cs中设置:gmapControl1.MapProvider = GMapProviders.OpenStreetMap;
  • 放置一张测试瓦片(如D:\tiles\15\16384\10240.png),重启验证是否加载成功。这是离线部署的关键一步。

3.3 调试技巧:如何在瓦片加载失败时快速定位?

GMAP.net 的网络请求由GMapProvider的GetTileAsync()触发,但错误日志默认不输出。你需要主动注入日志钩子:

// 在 MainForm.cs 的构造函数中 GMapProvider.TileLoadStart += (sender, e) => Debug.WriteLine($"[TILE START] {e.Url} (Zoom:{e.Zoom}, X:{e.TileX}, Y:{e.TileY})"); GMapProvider.TileLoadComplete += (sender, e) => Debug.WriteLine($"[TILE OK] {e.Url} -> {e.Data.Length} bytes"); GMapProvider.TileLoadFailed += (sender, e) => Debug.WriteLine($"[TILE FAIL] {e.Url} : {e.Exception?.Message}");

这样当瓦片加载失败时,Output 窗口会直接打印 URL 和异常信息。我曾遇到WebException: The remote server returned an error: (403) Forbidden,追查发现是 User-Agent 被某些 CDN 拦截——解决方案是在GetTileUrl()返回前加头:

WebRequest.DefaultWebProxy.Credentials = CredentialCache.DefaultCredentials; ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;

4. 常见问题排查:5 条血泪经验总结,覆盖 90% 新手翻车现场

GMAP.net 文档稀疏,社区问答零散,很多问题得靠 debug 源码才能解决。以下是我在三个工业项目中踩过的坑,按发生频率排序,每条都附可复现现象和根治方案。

4.1 现象:地图显示一片灰色,Network 选项卡无任何请求

原因:GMapControl的Enabled属性为false,或IsDragging状态卡死(常见于鼠标事件未释放)
解决:

  • 检查gmapControl1.Enabled是否为true(设计器属性或代码中显式设置)
  • 在MainForm_Load中强制重置状态:gmapControl1.IsDragging = false; gmapControl1.Refresh();
  • 若仍无效,在GMapControl.OnMouseDown断点,确认Capture = true是否执行(未执行则OnMouseUp永远不会触发)

4.2 现象:缩放时地图“抖动”,标记点位置随缩放跳变

原因:GMapControl的MinZoom/MaxZoom与MapProvider.MaxZoom不一致,导致ViewArea计算失准
解决:

  • 统一设置:gmapControl1.MinZoom = 2; gmapControl1.MaxZoom = 18;
  • 在GMapProvider子类中严格重写MaxZoom,并确保GetTileUrl()对越界 zoom 返回null(而非拼错 URL)
  • 关键修复:在GMapControl.SetZoomAndCenter()中,添加if (zoom > MaxZoom) zoom = MaxZoom;防御性校验

4.3 现象:自定义GMapMarker图标模糊,边缘锯齿严重

原因:GDI+ 默认双线性插值,对小尺寸图标(<32px)降质明显
解决:

  • 重写GMapMarker.OnRender(),使用Graphics.InterpolationMode = InterpolationMode.NearestNeighbor
  • 或预放大图标:提供 2x 尺寸 PNG(如 64×64),在OnRender()中DrawImage(icon, destRect, srcRect, GraphicsUnit.Pixel)指定精确裁剪区域
  • 更彻底方案:改用GMapMarkerCustom,用GraphicsPath绘制矢量图标(避免位图缩放)

4.4 现象:GMapRoute折线在高 Zoom 下断裂,部分线段消失

原因:GMapRoute的Points是List<PointLatLng>,但渲染时未做“视图裁剪”,超出ViewArea的点被丢弃导致折线中断
解决:

  • 在GMapRoute.OnRender()中,对每个PointLatLng调用projection.FromLatLngToPixel()后,检查是否在viewArea内:
    var pixel = projection.FromLatLngToPixel(p, zoom); if (pixel.X < viewArea.Left || pixel.X > viewArea.Right || pixel.Y < viewArea.Top || pixel.Y > viewArea.Bottom) continue;
  • 或启用GMapControl.ClipRoutes = true(需自行在GMapControl中添加该属性并修改渲染逻辑)

4.5 现象:SQLite 缓存目录被杀毒软件锁定,INSERT INTO tiles报SQLITE_BUSY

原因:Windows Defender 或第三方杀软实时扫描gmapcache.db文件,导致数据库写入阻塞
解决:

  • 在GMapCache构造函数中,为 SQLite 连接字符串添加BusyTimeout=5000:
    _connection = new SQLiteConnection($"Data Source={cachePath};BusyTimeout=5000;");
  • 或将缓存路径指向用户临时目录(避开杀软重点监控区):
    string cachePath = Path.Combine(Path.GetTempPath(), "GMapCache", "gmapcache.db");

提示:以上问题在 GitHub Issues 中高频出现,但官方回复常是“请升级到最新版”。实际验证发现,v2.0 到 v2.1 的核心逻辑几乎未变,问题根源在于使用方式而非版本缺陷。建议把GMap.NET.Core项目设为“始终复制”到输出目录,并用 Process Monitor 监控gmapcache.db的文件访问行为——这是最高效的排查手段。


5. 核心模块代码逐行解读:GMapControl.OnPaint渲染链路与自定义覆盖物注入点

GMapControl.OnPaint是整个地图渲染的“心脏”,它串联了瓦片加载、坐标转换、覆盖物绘制三大环节。读懂它,你就掌握了 80% 的定制能力。下面以 v2.1 源码为基础,逐段拆解其主干逻辑,并标注所有可安全 Hook 的扩展点。

5.1OnPaint主流程:从清屏到覆盖物的七步链路

protected override void OnPaint(PaintEventArgs e) { if (!IsHandleCreated || !Loaded || !Enabled) return; // Step 1: 获取当前视图参数(关键!所有计算以此为基准) var viewArea = ViewArea; // GRect 类型,含 Left/Top/Right/Bottom(单位:世界像素) var zoom = Zoom; var projection = Projection; // MercatorProjection 实例 // Step 2: 计算当前视图需加载的瓦片范围(核心性能点) var tileRange = GetTileRange(viewArea, zoom); // 返回 TileRange 结构体:MinX/MaxX/MinY/MaxY // Step 3: 并行加载可见瓦片(异步但阻塞渲染,因需等待全部完成) var tiles = LoadTiles(tileRange, zoom).Where(t => t.Data != null).ToArray(); // Step 4: 清空背景(重要:避免旧瓦片残留) e.Graphics.Clear(BackColor); // Step 5: 绘制瓦片(核心:像素坐标转换) foreach (var tile in tiles) { var pixelPos = projection.FromTileXYToPixel(tile.X, tile.Y, zoom); // 转换为世界像素 var clientPos = new Point( (int)(pixelPos.X - viewArea.Left), (int)(pixelPos.Y - viewArea.Top) ); e.Graphics.DrawImageUnscaled(tile.Data, clientPos); } // Step 6: 绘制覆盖物(Markers/Polylines/Polygons) DrawOverlays(e.Graphics, viewArea, zoom); // Step 7: 绘制控件装饰(比例尺、版权信息等) DrawControlElements(e.Graphics); }

参数与逻辑说明:

  • ViewArea是动态计算的,由Zoom和Position(中心经纬度)推导而来,它是所有坐标的锚点。修改Position会触发ViewArea重算,进而触发OnPaint。
  • GetTileRange()的算法是:MinX = Floor((viewArea.Left) / 256), MaxX = Ceil((viewArea.Right) / 256)—— 因为每张瓦片固定 256×256 像素。
  • LoadTiles()内部调用GMapCache.GetOrDownloadTile(),先查 SQLite,未命中则发 HTTP 请求。此处可注入自定义下载器(如加 Auth Header)。
  • DrawOverlays()是虚方法,GMapControl默认调用overlay.Render(),但你可以继承GMapOverlay并重写Render()实现自己的绘制逻辑。

5.2 自定义覆盖物:从GMapMarker到GMapOverlay的三级扩展体系

GMAP.net 的覆盖物不是简单“画个图”,而是分层渲染的体系。理解这三层,你就能做出雷达扫描、热力图、动态轨迹等高级效果。

层级类型职责扩展方式
Level 1GMapMarker单点标记(图标+气泡)继承GMapMarker,重写OnRender()绘制自定义图形;重写OnMouseClick()处理点击事件
Level 2GMapOverlay覆盖物容器(管理一组 Marker/Route)继承GMapOverlay,重写Render()控制绘制顺序;添加ZIndex属性控制图层叠放
Level 3GMapControl全局覆盖物管理器通过gmapControl1.Overlays.Add(myOverlay)注册;监听OverlayAdded/OverlayRemoved事件

看一个实战案例:实现“闪烁标记点”(模拟设备在线状态)

public class BlinkingMarker : GMapMarker { private readonly Timer _blinkTimer; private bool _isOn = true; public BlinkingMarker(PointLatLng pos) : base(pos) { _blinkTimer = new Timer { Interval = 800 }; _blinkTimer.Tick += (s, e) => { _isOn = !_isOn; gmapControl1?.Invalidate(); }; _blinkTimer.Start(); } public override void OnRender(Graphics g, Rectangle rect, GMapControl control) { if (!_isOn) return; // 闪烁时跳过绘制 // 绘制红色圆点(比内置图标更轻量) var pos = control.Projection.FromLatLngToPixel(Position, control.Zoom); var clientPos = new Point( (int)(pos.X - control.ViewArea.Left), (int)(pos.Y - control.ViewArea.Top) ); g.FillEllipse(Brushes.Red, clientPos.X - 5, clientPos.Y - 5, 10, 10); g.DrawEllipse(Pens.White, clientPos.X - 5, clientPos.Y - 5, 10, 10); } }

关键点:

  • gmapControl1.Invalidate()触发重绘,但只重绘脏区域(性能友好)
  • control.ViewArea必须从control参数获取,不能用GMapControl静态实例(多实例场景会错乱)
  • FillEllipse用Brushes.Red而非new SolidBrush(Color.Red),避免 GDI+ 资源泄漏

5.3 瓦片加载拦截:如何在不改GMapProvider的前提下注入认证头?

有些私有地图服务(如企业内网 MapServer)需要 Bearer Token。GMapProvider.GetTileUrl()只返回 URL,无法加 Header。此时要 HackGMapCache.LoadTileAsync():

// 在 GMap.NET.Core/GMapCache.cs 中,找到 LoadTileAsync 方法 public async Task<GMapTile> LoadTileAsync(string url, int zoom, int tileX, int tileY) { try { // 【Hook 点】在此处修改 HttpClient 行为 var client = new HttpClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "your-jwt-token-here"); client.DefaultRequestHeaders.UserAgent.ParseAdd("GMAP.net/2.1"); var response = await client.GetAsync(url); response.EnsureSuccessStatusCode(); var data = await response.Content.ReadAsByteArrayAsync(); return new GMapTile { Data = data, Url = url, Zoom = zoom, X = tileX, Y = tileY }; } catch (Exception ex) { Debug.WriteLine($"Tile load failed: {url} - {ex.Message}"); return null; } }

注意:HttpClient应复用(声明为 static readonly),否则高并发下会耗尽 socket。生产环境建议用IHttpClientFactory,但 GMAP.net 未集成 DI,所以直接 new 一个并加using是最稳妥的。


6. 二次开发实战:为 GMAP.net 添加 GeoJSON 矢量图层支持(含坐标系自动转换)

GeoJSON 是 GIS 数据交换的事实标准,但 GMAP.net 原生不支持。很多开发者想直接加载.geojson文件渲染行政区划、道路网,却卡在“坐标系不匹配”上——GeoJSON 用 WGS84(经纬度),而 GMAP.net 渲染用墨卡托像素。下面教你从零实现一个GMapGeoJsonOverlay,支持自动投影转换、样式定制、点击事件。

6.1 技术路线与依赖选择

  • GeoJSON 解析:用Newtonsoft.Json(GMAP.net 已引用)解析FeatureCollection
  • 坐标转换:复用GMap.NET.Core.Projection.MercatorProjection的FromLatLngToPixel()
  • 矢量绘制:用Graphics.DrawLines()(折线)、Graphics.FillPolygon()(面)
  • 事件绑定:在GMapControl.OnMouseClick中,用Projection.FromPixelToLatLng()反算点击点经纬度,再遍历所有Feature做点面判断

不引入新 NuGet 包,纯 C# 实现,保证与现有项目零冲突。

6.2 核心类GMapGeoJsonOverlay实现

public class GMapGeoJsonOverlay : GMapOverlay { public List<Feature> Features { get; } = new(); public Color StrokeColor { get; set; } = Color.Blue; public float StrokeWidth { get; set; } = 2f; public Color FillColor { get; set; } = Color.FromArgb(100, 0, 128, 255); // 半透明蓝 public Action<Feature> OnFeatureClick { get; set; } public void LoadFromFile(string path) { var json = File.ReadAllText(path); var geoJson = JsonConvert.DeserializeObject<GeoJson>(json); Features.Clear(); Features.AddRange(geoJson.Features); } public override void Render(Graphics g, Rectangle rect, GMapControl control) { if (!control.IsHandleCreated || Features.Count == 0) return; var projection = control.Projection; var zoom = control.Zoom; var viewArea = control.ViewArea; foreach (var feature in Features) { switch (feature.Geometry.Type) { case "Point": RenderPoint(g, feature, projection, zoom, viewArea, control); break; case "LineString": RenderLineString(g, feature, projection, zoom, viewArea, control); break; case "Polygon": RenderPolygon(g, feature, projection, zoom, viewArea, control); break; } } } private void RenderPoint(Graphics g, Feature feature, IProjection projection, int zoom, GRect viewArea, GMapControl control) { var coords = (JArray)feature.Geometry.Coordinates; var lat = (double)coords[1], lng = (double)coords[0]; var pixel = projection.FromLatLngToPixel(new PointLatLng(lat, lng), zoom); var clientPos = new Point( (int)(pixel.X - viewArea.Left), (int)(pixel.Y - viewArea.Top) ); g.FillEllipse(Brushes.Red, clientPos.X - 4, clientPos.Y - 4, 8, 8); } private void RenderLineString(Graphics g, Feature feature, IProjection projection, int zoom, GRect viewArea, GMapControl control) { var coords = (JArray)feature.Geometry.Coordinates; var points = new Point[coords.Count]; for (int i = 0; i < coords.Count; i++) { var coord = (JArray)coords[i]; var lng = (double)coord[0], lat = (double)coord[1]; var pixel = projection.FromLatLngToPixel(new PointLatLng(lat, lng), zoom); points[i] = new Point( (int)(pixel.X - viewArea.Left), (int)(pixel.Y - viewArea.Top) ); } using (var pen = new Pen(StrokeColor, StrokeWidth)) g.DrawLines(pen, points); } private void RenderPolygon(Graphics g, Feature feature, IProjection projection, int zoom, GRect viewArea, GMapControl control) { var coords = (JArray)((JArray)feature.Geometry.Coordinates)[0]; // 取外环 var points = new Point[coords.Count]; for (int i = 0; i < coords.Count; i++) { var coord = (JArray)coords[i]; var lng = (double)coord[0], lat = (double)coord[1]; var pixel = projection.FromLatLngToPixel(new PointLatLng(lat, lng), zoom); points[i] = new Point( (int)(pixel.X - viewArea.Left), (int)(pixel.Y - viewArea.Top) ); } using (var brush = new SolidBrush(FillColor)) g.FillPolygon(brush, points); using (var pen = new Pen(StrokeColor, StrokeWidth)) g.DrawPolygon(pen, points); } }

6.3 在 Demo 中集成与验证

在MainForm.cs中添加:

private void LoadGeoJson_Click(object sender, EventArgs e) { var overlay = new GMapGeoJsonOverlay("geojson-overlay"); overlay.LoadFromFile(@"C:\data\provinces.geojson"); // 下载一个中国省级 GeoJSON 测试 overlay.StrokeColor = Color.Orange; overlay.FillColor = Color.FromArgb(50, 255, 165, 0); // 半透明橙色 overlay.OnFeatureClick = feature => MessageBox.Show($"Clicked: {feature.Properties["name"]}"); gmapControl1.Overlays.Add(overlay); gmapControl1.Zoom = 4; gmapControl1.Position = new PointLatLng(35.0, 105.0); // 中国中心 } // 拦截鼠标点击,触发 GeoJSON 事件 private void gmapControl1_OnMouseClick(object sender, MouseEventArgs e) { // 反算点击点经纬度 var point = gmapControl1.FromLocalToLatLng(e.X, e.Y); // 遍历所有 overlay 查找点击的 Feature(简化版:只查第一个 overlay) foreach (GMapGeoJsonOverlay overlay in gmapControl1.Overlays) { foreach (var feature in overlay.Features) { if (IsPointInFeature(point, feature)) { overlay.OnFeatureClick?.Invoke(feature); return; } } } } private bool IsPointInFeature(PointLatLng point, Feature feature) { // 实现点面判断(Winding Number 算法),此处略,可用 NetTopologySuite 库 return false; }

6.4 避坑指南:GeoJSON 坐标系陷阱与性能优化

  • 陷阱 1:GeoJSON 坐标顺序:RFC 7946 规定[longitude, latitude],但很多国产工具导出为[latitude, longitude]。加载后若地图“倒置”,立刻检查Coordinates[0]是经度还是纬度。
  • 陷阱 2:大数据量卡顿:一个省级 GeoJSON 可能含 10 万顶点。RenderPolygon()中FillPolygon()会触发 GDI+ 复杂填充,CPU

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询