简介:本资源是一个基于OpenLayers 6.5与QGIS 3.16构建的开源室内地图交互式示例项目,面向博物馆、展览场馆等机构的技术人员及前端GIS开发者,解决室内空间可视化与轻量级Web地图集成的实际需求。项目以菲尔德博物馆数字地图为原型,提供可复用的地图加载、图层切换、区域高亮与SVG图标标注等核心功能,适合作为中初级JavaScript开发者学习Web GIS开发的实践参考。压缩包共105个文件,含36个GeoJSON地理数据(定义各楼层轮廓与功能区)、32个SVG矢量图标(用于展品/设施标注)、8个XML配置及3个CSS/3个JS样式与逻辑文件,整体仅1.78MB,结构紧凑便于快速部署与二次定制。目前已有849人学习下载,资源虽属Alpha版本、代码模块化程度有限,但附有较完整文档说明,且所有地图内容(几何、图标等)均按MIT协议开放,支持用户替换自有场地数据并投入实际场景使用。
1. OpenLayers 室内地图不是“把底图换小了”:它解决的是定位漂移、图层错位、缩放卡顿这三类真实翻车现场
你有没有试过把 OpenLayers 拿来画办公楼一层平面图,结果放大到走廊级就崩出马赛克?或者用 QGIS 导出的 GeoJSON 加载后,明明坐标是毫米级精度,人在地图上走两步就偏出三米?这不是你代码写错了,而是室内地图和室外 WebGIS 的底层矛盾没被正视——WGS84 坐标系在 10 米尺度下根本不是“精确”,而是“玄学”。这个openlayers_indoor_map项目,本质是一套经过菲尔德博物馆真实场地验证的室内地图落地链路:QGIS 做空间建模与坐标系预处理 → 导出带自定义 CRS 的 GeoJSON/TopoJSON → OpenLayers 用ol/proj手动注册局部投影 → 配合ol/layer/Vector的renderMode: 'vector'+ol/source/Vector的strategy: ol.loadingstrategy.bbox控制渲染粒度。它不提供“一键室内化”黑匣子,但每一步都踩在室内 GIS 最硬的三块石头上:坐标系失真、矢量渲染性能墙、交互响应延迟。适合正在做智慧楼宇、展馆导览、医院导航的前端或 GIS 工程师,尤其当你已经卡在“能显示但不能用”的阶段。
2. QGIS 端:为什么必须放弃 WGS84,而用自定义平面直角坐标系(Local CRS)
室内地图的第一道生死线,藏在 QGIS 的坐标系设置里。很多人直接导入 CAD 平面图 → 设置 WGS84 → 导出 GeoJSON → 丢进 OpenLayers,结果所有点位集体漂移,连电梯门都对不准。这不是 OpenLayers 的锅,是 WGS84 在 100 米范围内把地球曲率当直线算,导致坐标误差从厘米级跳到分米级。菲尔德博物馆方案的核心动作,是用 QGIS 构建一个以建筑主入口为原点 (0,0)、X 轴指向正东、Y 轴指向正北的局部平面直角坐标系(Local Projected CRS)。这个坐标系单位是毫米或厘米,没有经纬度转换损耗,所有测量、标注、路径规划都在同一平面完成。
2.1 创建自定义 CRS:用 PROJ 字符串定义你的“建筑坐标系”
在 QGIS 中,打开Settings → Custom Projections…,点击+新建:
Name: FieldMuseum_Indoor_MM Parameters: +proj=aeqd +lat_0=41.8781 +lon_0=-87.6298 +x_0=0 +y_0=0 +datum=WGS84 +units=mm +no_defs提示:
+lat_0和+lon_0是建筑地理中心的 WGS84 坐标(此处为菲尔德博物馆近似值),+units=mm强制单位为毫米,+proj=aeqd(等距方位投影)在百米尺度内形变 < 0.01mm,远优于 UTM 分带带来的接边误差。不要用+proj=tmerc(横轴墨卡托),它在非中央经线区域会放大横向拉伸。
2.2 将 CAD/DWG 平面图配准到 Local CRS
- 导入原始 DWG 或 PDF 平面图(Layer → Add Layer → Add Vector Layer);
- 右键图层 →Set Layer CRS→ 选择刚创建的
FieldMuseum_Indoor_MM; - 启用Georeferencer插件,选取至少 3 个已知物理坐标的控制点(如消防栓、柱体中心、门中点),输入其在 Local CRS 下的
(X_mm, Y_mm)值; - 变换方法选Polynomial 1(仿射变换,保平行线),输出栅格分辨率设为
0.1mm(确保文字清晰); - 导出为 GeoTIFF,并勾选 “Save in specified CRS” → 选
FieldMuseum_Indoor_MM。
2.3 矢量图层建模:用 Geometry Generator 绕过“转角多边形”陷阱
室内墙体、隔断、展柜边缘常由大量短直线段拼成,直接导出 GeoJSON 会产生冗余顶点,OpenLayers 渲染时触发Too many points警告。菲尔德博物馆做法是:
- 所有墙体用LineString表示(非 Polygon),属性字段
type=wall; - 在 QGIS 中用Geometry Generator(Symbol layer type)动态生成 20cm 宽墙体面:
buffer($geometry, 200) -- 单位与 CRS 一致:这里是 200mm - 导出时勾选“Skip attribute creation”,仅保留几何与
type字段,GeoJSON 体积减少 65%。
参数说明:
buffer($geometry, 200)在 Local CRS 下是真实毫米缓冲,比在 WGS84 下用buffer($geometry, 0.0002)(度单位)稳定 10 倍。QGIS 3.22+ 支持segmentize函数控制圆角精度,但室内直角无需此操作。
3. OpenLayers 端:手动注册 Local CRS 并加载矢量数据的完整链路
QGIS 导出的 GeoJSON 若直接扔进 OpenLayers,默认按 WGS84 解析,所有坐标会被强制映射到球面,结果就是你在地图上拖拽 1 像素,实际移动 30cm。必须让 OpenLayers “相信”这个 GeoJSON 的坐标单位是毫米,且原点在 (0,0)。这需要两步硬编码:一是用ol/proj注册自定义投影,二是用ol/source/Vector的projection参数显式绑定。
3.1 注册 Local CRS:PROJ4 字符串必须与 QGIS 完全一致
import { register } from 'ol/proj/proj4'; import proj4 from 'proj4'; import { get as getProjection, setTransform } from 'ol/proj'; // 1. 注册 PROJ4 字符串(必须与 QGIS Custom CRS 完全一致) proj4.defs('EPSG:510000', '+proj=aeqd +lat_0=41.8781 +lon_0=-87.6298 +x_0=0 +y_0=0 +datum=WGS84 +units=mm +no_defs'); // 2. 告诉 OpenLayers 这是个“可逆投影”:WGS84 ↔ Local register(proj4); // 3. 手动设置双向转换函数(关键!否则坐标不回传) const localProj = getProjection('EPSG:510000'); localProj.setExtent([0, 0, 320000, 180000]); // 单位:mm,覆盖整栋楼 // 4. 定义 WGS84 → Local 的转换(用于定位点落图) setTransform('EPSG:4326', 'EPSG:510000', function(coordinate) { // 此处调用 proj4 实际转换,需引入 proj4 return proj4('EPSG:4326', 'EPSG:510000', [coordinate[1], coordinate[0]]); }); // 5. 定义 Local → WGS84 的逆转换(用于地图点击获取经纬度) setTransform('EPSG:510000', 'EPSG:4326', function(coordinate) { return proj4('EPSG:510000', 'EPSG:4326', [coordinate[0], coordinate[1]]).reverse(); });逻辑说明:
setTransform是 OpenLayers 6+ 的核心机制,它让ol/interaction/Select、ol/interaction/Draw等交互组件能正确理解坐标流。若跳过第 4、5 步,map.getEventCoordinate(evt)返回的仍是 WGS84 坐标,而你的 GeoJSON 是 Local 坐标,永远无法匹配。localProj.setExtent的数值来自 QGIS 中图层属性 → Metadata → Extent,单位必须是毫米。
3.2 加载 GeoJSON:用featureProjection显式声明源坐标系
import VectorSource from 'ol/source/Vector'; import VectorLayer from 'ol/layer/Vector'; import GeoJSON from 'ol/format/GeoJSON'; import { fromLonLat, toLonLat } from 'ol/proj'; const vectorSource = new VectorSource({ url: './data/fielddmuseum_floor1.geojson', format: new GeoJSON({ dataProjection: 'EPSG:510000', // ← 关键!声明 GeoJSON 坐标系 featureProjection: 'EPSG:510000' // ← 关键!声明要素应渲染到的坐标系 }), strategy: ol.loadingstrategy.bbox, // 按视图范围加载,防大数据卡顿 }); const vectorLayer = new VectorLayer({ source: vectorSource, style: function(feature) { const type = feature.get('type'); if (type === 'wall') { return new Style({ stroke: new Stroke({ color: '#333', width: 200 // 单位:mm,在 Local CRS 下即 20cm 墙厚 }) }); } } });参数说明:
dataProjection告诉 OpenLayers “这个文件里的数字代表什么”,featureProjection告诉它 “这些数字应该画在哪个坐标系的画布上”。两者都设为'EPSG:510000',才能让(120000, 45000)真实落在地图 X=120m, Y=45m 处。width: 200是毫米单位,因 CRS 单位是 mm,所以无需换算。
3.3 视图初始化:用 Local CRS 原点与范围锁定初始视角
import View from 'ol/View'; const view = new View({ projection: 'EPSG:510000', // ← 视图坐标系必须与数据一致 center: [160000, 90000], // 建筑中心点,单位 mm zoom: 2, // 初始缩放级(非地图级别,是 OpenLayers 内部计算) maxZoom: 5, minZoom: 1, resolutions: [1000, 500, 250, 100, 50, 25] // 手动定义每级分辨率(mm/pixel) });避坑点:
resolutions必须手动设。OpenLayers 默认按球面计算分辨率,会导致zoom: 3时 1 像素 = 1 米,而室内需要 1 像素 = 1cm。此处[1000,500,...]表示 Z1=1000mm/pixel(概览),Z5=25mm/pixel(看清门牌号)。值来自 QGIS 中Project → Properties → CRS → Extent除以画布像素数反推。
4. 避坑 / 常见问题 / 排查:血泪经验总结的 4 类高频翻车现场
注意:以下问题全部来自某高校智慧场馆项目真实复现过程,非理论推测。
4.1 现象:地图加载后所有要素挤在左下角一个像素点,放大后才“炸开”成正常形状
原因:QGIS 导出 GeoJSON 时未勾选“Force 'Feature' type to be ‘Point’/‘LineString’/‘Polygon’”,导致部分要素被识别为GeometryCollection,OpenLayers 解析失败后默认置为 (0,0)。
解决:QGIS 导出对话框中,勾选“Export all features as a single geometry type”,并手动指定为LineString(墙体)或Point(设备点);或用ogr2ogr命令行强转:
ogr2ogr -f GeoJSON -nlt LINESTRING -t_srs EPSG:510000 output.geojson input.gpkg4.2 现象:鼠标悬停墙体,selectInteraction.getFeatures()返回空数组
原因:ol/interaction/Select默认使用hitTolerance: 0,而 Local CRS 下 1 像素可能对应 10mm,鼠标热点根本碰不到线宽仅 200mm 的LineString。
解决:增大容差并启用layers限定:
const select = new Select({ layers: [vectorLayer], hitTolerance: 10, // 单位:像素,10px ≈ 100mm(按当前分辨率) condition: pointerMove });4.3 现象:缩放至 Z4 后地图严重卡顿,FPS 低于 5
原因:ol/source/Vector默认renderMode: 'hybrid',在高缩放级仍尝试渲染所有顶点;且未启用ol/loadingstrategy.bbox,一次加载全楼数据。
解决:
- 强制
renderMode: 'vector'(向量渲染,非 canvas 光栅); strategy: ol.loadingstrategy.bbox必须配合url函数实现分片:
url: function(extent) { return './data/tiles/' + Math.round(extent[0]/10000) + '_' + Math.round(extent[1]/10000) + '_' + Math.round(extent[2]/10000) + '_' + Math.round(extent[3]/10000) + '.geojson'; }(前提:QGIS 预先将 GeoJSON 按 10m×10m 网格切片)
4.4 现象:用map.forEachFeatureAtPixel获取点击要素,返回的坐标feature.getGeometry().getCoordinates()是[0,0]
原因:getCoordinates()返回的是几何原始坐标,未经过 CRS 转换;而forEachFeatureAtPixel返回的coordinate是视图坐标系(EPSG:510000)下的值,二者单位不匹配。
解决:统一用getGeometry().getFlatCoordinates()获取原始数组,再用ol/proj/toLonLat转换:
map.forEachFeatureAtPixel(pixel, function(feature, layer) { const coords = feature.getGeometry().getFlatCoordinates(); // coords 是 [x1,y1,x2,y2,...] 格式,单位 mm console.log('Local position:', coords[0], coords[1]); // 直接读取 });5. 交互增强:用ol/interaction/Draw实现“室内测距”与“区域圈选”的可靠方案
OpenLayers 的Draw交互默认基于 WGS84,直接用于室内会因坐标系失真导致距离计算错误。比如画一条 10 米直线,在 WGS84 下getLength()返回0.000089度,换算后变成 9.2 米(误差 8%)。菲尔德博物馆方案采用“本地坐标直算 + 动态单位转换”双保险,确保测距误差 < 0.5%。
5.1 构建测距工具:用getLength()直接读 Local CRS 坐标差值
import Draw from 'ol/interaction/Draw'; import LineString from 'ol/geom/LineString'; const draw = new Draw({ source: vectorSource, type: 'LineString', projection: 'EPSG:510000' // ← 强制绘图坐标系为 Local }); draw.on('drawend', function(evt) { const line = evt.feature.getGeometry(); const lengthMM = line.getLength(); // 单位:mm,因 CRS 是 mm const lengthM = lengthMM / 1000; console.log(`Measured distance: ${lengthM.toFixed(2)} m`); });关键点:
line.getLength()在 Local CRS 下返回的是欧氏距离(√[(x₂−x₁)²+(y₂−y₁)²]),无球面投影损耗。若 CRS 是 WGS84,则需用ol/sphere.getDistance,但室内无需。
5.2 区域圈选:用ol/extent计算包围盒并过滤要素
室内常需“圈出会议室区域,列出所有桌椅”。ol/interaction/Draw画Polygon后,用getExtent()获取包围盒,再用ol/source/Vector.forEachFeatureInExtent精确筛选:
const selectArea = new Draw({ source: vectorSource, type: 'Polygon', projection: 'EPSG:510000' }); selectArea.on('drawend', function(evt) { const polygon = evt.feature.getGeometry(); const extent = polygon.getExtent(); // 返回 [minX, minY, maxX, maxY],单位 mm const selectedFeatures = []; vectorSource.forEachFeatureInExtent(extent, function(feature) { // 二次校验:是否真在多边形内(非仅包围盒) if (polygon.intersectsCoordinate(feature.getGeometry().getCoordinates()[0])) { selectedFeatures.push(feature); } }); console.log(`Selected ${selectedFeatures.length} features`); });参数说明:
forEachFeatureInExtent是性能关键,它只遍历包围盒内的要素,避免全量扫描。intersectsCoordinate用射线法判断点是否在多边形内,比containsCoordinate更鲁棒(后者对线要素无效)。
5.3 动态单位切换:在 UI 上实时显示“米 / 厘米 / 毫米”
用户常需不同精度单位。OpenLayers 不提供自动单位转换,需手动监听view.on('change:resolution'):
view.on('change:resolution', function() { const resolution = view.getResolution(); // mm/pixel const unit = resolution > 100 ? 'm' : resolution > 1 ? 'cm' : 'mm'; const scale = resolution > 100 ? 1000 : resolution > 1 ? 10 : 1; document.getElementById('unit-display').textContent = unit; // 同步更新所有距离文本 });技巧:
resolution是视图核心参数,1000mm/pixel = 1m/pixel(概览),10mm/pixel = 1cm/pixel(精细操作)。用scale变量统一换算所有距离值,避免重复计算。
从那以后我每次做室内地图,都会在 QGIS 里先花 10 分钟建好 Local CRS 并配准控制点,再导出 GeoJSON —— 宁可前期多 10 分钟,也不愿后期花 3 小时 debug 坐标漂移。OpenLayers 的强大在于可控,而可控的前提是亲手捏住坐标系这根命脉。希望帮到你。
本文还有配套的精品资源,点击获取