基于鸿蒙OS开发静脉输液智能监控系统(13)-医院室内导航系统设计
基于鸿蒙OS开发静脉输液智能监控系统(13)-医院室内导航系统设计
1. 室内导航技术概述
1.1 为什么医院需要室内导航
医院作为复杂的公共建筑,其室内空间具有以下显著特征:
- 楼层众多:大型综合医院通常拥有3-20层楼,每层布局各异
- 功能分区复杂:门诊、急诊、输液室、住院部、手术室、药房、检验科等功能区域分散
- 患者方向感弱:患者在就医过程中常处于焦虑状态,对陌生环境的辨识能力下降
- 寻路成本高:患者因找不到目的地而频繁询问医护人员,消耗大量医疗资源
- 紧急时间窗口:急诊患者需要在最短时间内到达救治区域,每一分钟都至关重要
根据相关研究统计,超过30%的门诊患者曾因找不到科室而迟到或错过就诊,约15%的急诊转运因路径不熟悉而延误。对于IVGuard项目所关注的输液场景,患者从挂号处前往输液室、从输液室前往护士站求助、从病房前往输液室接受治疗,这些高频路径都需要精确的室内导航支持。
1.2 GPS在室内的局限性
全球定位系统(GPS,Global Positioning System)是当前最广泛应用的室外定位技术,其基本原理是通过接收至少4颗卫星的信号,利用三边测量法计算接收器的三维坐标。然而,GPS信号在室内环境中面临根本性的物理障碍:
1.2.1 信号衰减机制
GPS信号工作在L1频段(1575.42 MHz),属于微波频段。该频段信号的传播特性决定了其穿透能力有限:
- 自由空间传播损耗:信号强度与距离的平方成反比衰减
- 建筑物遮挡损耗:混凝土墙壁对L1频段信号的衰减约10-20 dB/层
- 玻璃窗衰减:低辐射玻璃(Low-E玻璃)衰减可达20-30 dB
- 金属结构反射:医院建筑中的钢筋结构造成严重的多径效应
典型的GPS接收器在室外可实现-130 dBm级别的信号接收灵敏度,而在室内信号强度通常低于-150 dBm,远低于接收器的最低工作门限。
1.2.2 多径效应
室内环境中,GPS信号经过墙壁、地面、天花板等表面的反射和散射后,接收器收到的是多个路径信号的叠加。多径效应导致:
- 伪距测量误差:反射路径比直射路径更长,导致距离计算偏差
- 载波相位跳变:多径信号干涉导致载波相位不稳定
- 定位结果跳变:位置解算在多个反射点之间跳跃
1.2.3 精度表现
| 环境 | GPS精度 | 可用性 |
|---|---|---|
| 开阔室外 | 3-5 m | 95%+ |
| 城市峡谷 | 10-50 m | 80%+ |
| 靠近窗户 | 20-100 m | 40-60% |
| 室内深处 | 不可用 | <5% |
结论:GPS在医院室内导航场景下完全不可用,必须采用专门的室内定位技术。
1.3 WiFi指纹定位
WiFi指纹定位(WiFi Fingerprinting)是目前最成熟的室内定位技术之一,其核心思想是利用室内已部署的WiFi接入点(Access Point, AP)的信号强度特征作为位置指纹。
1.3.1 工作原理
WiFi指纹定位分为两个阶段:
离线采集阶段(Training Phase):
- 在目标建筑的每个参考点(Reference Point, RP)进行信号采集
- 参考点间距通常为1-3米
- 在每个参考点,扫描周围所有可见AP的BSSID和RSSI(Received Signal Strength Indicator)
- 对多次扫描结果取平均,构建该参考点的指纹向量
- 将所有参考点的指纹向量存入指纹数据库
指纹向量的数据结构:
Fingerprint = {
location: (x, y, floor),
AP_signals: {
"AA:BB:CC:DD:EE:01": -45, // AP1的RSSI
"AA:BB:CC:DD:EE:02": -62, // AP2的RSSI
"AA:BB:CC:DD:EE:03": -71, // AP3的RSSI
...
}
}
在线定位阶段(Positioning Phase):
- 移动设备实时扫描周围AP的RSSI
- 将实时扫描结果与指纹数据库中的记录进行匹配
- 使用匹配算法计算最可能的位置
1.3.2 匹配算法
最邻近法(NN, Nearest Neighbor):
选择指纹数据库中与实时信号向量欧氏距离最小的参考点作为估计位置。
距离 = sqrt(Σ(RSSI_observed_i - RSSI_fingerprint_i)²)
K最邻近法(KNN, K-Nearest Neighbor):
选择距离最小的K个参考点,取其位置的加权平均作为估计位置。
位置估计 = Σ(w_k × pos_k) / Σ(w_k)
其中 w_k = 1 / dist_k (距离越近权重越大)
概率法(Probabilistic Method):
利用信号强度的概率分布(通常假设高斯分布),计算在给定观测信号下各参考点的后验概率,选择概率最大的位置。
P(location | signal) ∝ P(signal | location) × P(location)
1.3.3 WiFi指纹定位的优缺点
优点:
- 利用现有WiFi基础设施,无需额外硬件部署
- 覆盖范围广,单个AP覆盖半径可达30-50m
- 智能手机原生支持WiFi扫描,无需专用设备
- 成本相对较低
缺点:
- 精度有限,典型3-5m
- 指纹库易受环境影响(人员密度、家具移动、AP位置变化)
- 需要定期重新采集指纹库(通常每3-6个月)
- 采集阶段工作量大(大型建筑可能需要数千个参考点)
- 扫描间隔较长(Android约10-15秒一次扫描)
1.4 蓝牙信标(iBeacon)定位
蓝牙信标定位基于低功耗蓝牙(Bluetooth Low Energy, BLE)技术,以Apple提出的iBeacon协议最为典型。
1.4.1 iBeacon协议
iBeacon设备通过广播特定格式的数据包来标识自身:
iBeacon数据包结构:
┌──────────────────────────────────────────────────┐
│ UUID (16 bytes) │ Major (2 bytes) │ Minor (2 bytes) │ TX Power (1 byte) │
└──────────────────────────────────────────────────┘
- UUID:标识信标所属的组织或应用(如医院统一UUID)
- Major:标识信标所属的群组(如楼层编号)
- Minor:标识群组内的具体信标(如房间编号)
- TX Power:发射功率校准值,用于距离估算
1.4.2 距离估算模型
基于RSSI的距离估算使用对数距离路径损失模型:
RSSI(d) = RSSI(d₀) - 10 × n × log₁₀(d / d₀) + X_σ
其中:
- d: 接收器与信标的距离
- d₀: 参考距离(通常1米)
- RSSI(d₀): 1米处的参考RSSI值(即TX Power)
- n: 路径损失指数(室内通常1.5-3.5)
- X_σ: 随机阴影衰落(通常σ=4-10 dB)
由此推导距离估计:
d = d₀ × 10^((RSSI(d₀) - RSSI_measured) / (10 × n))
实际应用中,通常将距离划分为几个区域:
| 区域 | 距离范围 | 典型RSSI |
|---|---|---|
| Immediate | < 0.5m | > -40 dBm |
| Near | 0.5-3m | -40 ~ -60 dBm |
| Far | 3-10m | -60 ~ -80 dBm |
| 不可见 | > 10m | < -80 dBm |
1.4.3 三角定位
当设备同时接收到3个或更多信标的信号时,可以通过三角定位法计算设备位置:
- 测量设备到每个信标的距离 d₁, d₂, d₃
- 以每个信标位置为圆心,对应距离为半径画圆
- 三个圆的交点即为设备位置
数学模型:
(x - x₁)² + (y - y₁)² = d₁²
(x - x₂)² + (y - y₂)² = d₂²
(x - x₃)² + (y - y₃)² = d₃²
由于距离测量存在误差,三个圆通常不会精确交于一点,需要使用最小二乘法求解最优估计。
1.4.4 部署建议
在医院环境中部署iBeacon的建议方案:
- 部署密度:每10-15米部署一个信标,确保任意位置至少能接收到3个信标信号
- 安装位置:天花板中央,高度2.5-3m,避免金属遮挡
- 编码规范:
- UUID:医院统一标识
- Major:楼层编号(1F=1, 2F=2, 3F=3)
- Minor:区域编号(护士站=01, 输液室=02, 急诊=03, 药房=04, 住院部=05)
- 发射间隔:100ms(默认)或500ms(省电模式)
- 电池寿命:CR2032纽扣电池约1-2年
1.5 UWB超宽带定位
超宽带(Ultra-Wideband, UWB)定位技术是目前精度最高的室内定位方案。
1.5.1 技术原理
UWB使用极窄的脉冲信号(纳秒级)进行通信和测距,其信号带宽超过500MHz(或相对带宽大于20%)。UWB定位的核心技术是飞行时间测距(Time of Flight, ToF):
距离 = 光速 × 飞行时间 / 2
d = c × Δt / 2
其中:
c = 299,792,458 m/s
Δt = 信号往返时间
由于纳秒级脉冲的时间分辨率极高,ToF测距的精度可以达到厘米级。
1.5.2 UWB定位模式
TWR(Two-Way Ranging,双向测距):
标签 → 锚点: Poll消息 (t₁)
锚点 → 标签: Response消息 (t₂)
标签 → 锚点: Final消息 (t₃)
飞行时间 = ((t₂ - t₁) + (t₃ - t₂) - 处理时间) / 2
距离 = 飞行时间 × 光速
TDoA(Time Difference of Arrival,到达时间差):
多个锚点同时接收标签发送的脉冲信号,通过信号到达各锚点的时间差计算标签位置。该模式需要锚点之间严格时钟同步。
1.5.3 UWB在医院的应用场景
UWB的高精度特性使其特别适合以下医院场景:
- 高价值设备追踪:追踪移动医疗设备(推车、监护仪等)位置,精度0.1-0.3m
- 婴儿防盗:新生儿佩戴UWB脚环,实时监控位置,离开授权区域立即报警
- 手术患者追踪:从病房到手术室的全程精确追踪
- 医护人员调度:精确知道每位医护人员的实时位置,优化调度
1.5.4 局限性
- 成本高:每个UWB锚点价格约500-2000元,标签价格约50-200元
- 部署密度:需要每20-30m部署一个锚点
- 功耗:比BLE高,标签电池寿命约数月
- 手机支持:目前仅有少数高端手机内置UWB芯片
1.6 各方案对比
| 维度 | GPS | WiFi指纹 | BLE信标 | UWB |
|---|---|---|---|---|
| 精度 | 10-50m(室外) | 3-5m | 1-3m | 0.1-0.3m |
| 室内可用性 | 不可用 | 可用 | 可用 | 可用 |
| 硬件成本 | 无(手机内置) | 低(利用现有AP) | 中(信标50-150元/个) | 高(锚点500-2000元/个) |
| 部署难度 | N/A | 高(指纹采集工作量大) | 中(信标安装+配置) | 高(锚点安装+同步) |
| 维护成本 | 无 | 高(定期重采指纹) | 低(换电池) | 中(设备校准) |
| 手机兼容性 | 通用 | 通用 | 需BLE 4.0+ | 需UWB芯片 |
| 响应延迟 | 1-2s | 10-15s | 1-3s | <1s |
| 楼层识别 | 不可靠 | 可靠(指纹含楼层信息) | 可靠(Major编码) | 可靠(3D定位) |
| 适用场景 | 室外导航 | 区域级定位 | 房间级定位 | 厘米级定位 |
IVGuard MVP选择:当前版本采用静态平面图+手动定位的方案,原因如下:
- MVP阶段聚焦核心输液监护功能,室内导航作为辅助体验
- 避免引入硬件部署依赖,降低MVP验证门槛
- 为后续版本预留定位技术升级接口
- 静态平面图已能解决大部分寻路需求(查看地图、了解布局、搜索POI)
后续版本路线图:
- v1.1: 集成BLE信标定位
- v1.2: WiFi指纹+BLE融合定位
- v2.0: UWB高精度定位+实时路径引导
2. HarmonyOS定位与地图能力
2.1 位置权限配置
HarmonyOS对位置信息的访问实行严格的权限管控。应用必须在module.json5中声明所需权限,并在运行时向用户申请授权。
2.1.1 权限声明
在module.json5的requestPermissions字段中声明位置相关权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.APPROXIMATELY_LOCATION",
"reason": "$string:location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
},
{
"name": "ohos.permission.LOCATION",
"reason": "$string:precise_location_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
权限层级关系:
| 权限 | 精度 | 授权方式 | 说明 |
|---|---|---|---|
APPROXIMATELY_LOCATION |
约5km | 用户授权 | 粗略位置,适用于城市级服务 |
LOCATION |
约5m | 用户授权 | 精确位置,需要同时获得APPROXIMATELY_LOCATION |
注意:LOCATION权限隐含APPROXIMATELY_LOCATION权限,但申请时需要两者都声明。
2.1.2 运行时权限申请
import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'
async function requestLocationPermission(): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager()
const permissions: Array<Permissions> = [
'ohos.permission.APPROXIMATELY_LOCATION',
'ohos.permission.LOCATION'
]
try {
const result = await atManager.requestPermissionsFromUser(
globalThis.context, permissions
)
return result.authResults[0] === 0
} catch (err) {
return false
}
}
2.1.3 WiFi信息获取权限
对于WiFi指纹定位,还需要获取WiFi扫描结果的权限:
{
"name": "ohos.permission.GET_WIFI_INFO",
"reason": "$string:wifi_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
2.2 MapKit地图SDK
HarmonyOS提供了MapKit地图服务SDK,支持在线地图的渲染和交互。
2.2.1 MapKit核心能力
import { MapComponent, mapCommon, map } from '@kit.MapKit'
MapKit提供的主要功能包括:
- 地图渲染:在线地图瓦片加载和渲染
- 标记添加:在地图上添加自定义标记点
- 路线规划:调用导航SDK进行路径规划
- 定位显示:在地图上显示用户当前位置
- 手势交互:缩放、平移、旋转等手势操作
2.2.2 MapComponent组件使用
@Component
struct MapPage {
private mapOptions?: mapCommon.MapOptions
private callback?: map.MapCallback
private mapController?: map.MapComponentController
aboutToAppear() {
this.mapOptions = {
position: { x: 0, y: 0 },
width: '100%',
height: '100%',
mapType: mapCommon.MapType.STANDARD
}
this.callback = {
onMapLoaded: () => {
console.info('Map loaded')
}
}
}
build() {
Stack() {
MapComponent({ mapOptions: this.mapOptions, mapCallback: this.callback })
.onLoad(() => {
console.info('MapComponent loaded')
})
}
}
}
2.2.3 MapKit的局限性
对于医院室内导航场景,MapKit存在以下局限:
- 无室内地图数据:MapKit主要提供室外街道地图,医院内部的楼层平面图需要自行构建
- 无室内POI数据:护士站、输液室等医院内部POI不在地图数据库中
- 无室内路径规划:MapKit的路线规划仅支持室外道路
- 无楼层概念:MapKit是2D地图,不支持多楼层切换
因此,IVGuard的室内导航系统选择自行实现地图渲染和导航逻辑,而非依赖MapKit。
2.3 WiFi信息获取
HarmonyOS提供了WiFi管理接口,可用于获取WiFi扫描结果以支持指纹定位:
import { wifiManager } from '@kit.ConnectivityKit'
function scanWifiInfo(): Array<wifiManager.WifiScanInfo> {
try {
wifiManager.scan()
const scanResults = wifiManager.getScanInfoList()
return scanResults
} catch (err) {
return []
}
}
WifiScanInfo对象包含的关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| ssid | string | 网络名称 |
| bssid | string | AP的MAC地址 |
| rssi | number | 信号强度(dBm) |
| band | number | 频段(2.4G/5G) |
| frequency | number | 中心频率(MHz) |
2.4 当前MVP方案:静态平面图+手动定位
考虑到MVP阶段的时间和资源约束,当前版本采用以下技术方案:
2.4.1 方案架构
┌─────────────────────────────────────────┐
│ HospitalNavPage │
│ ┌──────────┐ ┌────────────────────┐ │
│ │ 楼层Tab │ │ HospitalMapView │ │
│ │ 1F/2F/3F │ │ Canvas 2D渲染 │ │
│ └──────────┘ │ - 平面图绘制 │ │
│ │ - POI标记 │ │
│ ┌──────────┐ │ - 导航路径 │ │
│ │ 快捷按钮 │ │ - 用户位置 │ │
│ │ 护士站 │ └────────────────────┘ │
│ │ 输液室 │ ┌────────────────────┐ │
│ │ 急诊 │ │ 目的地信息卡片 │ │
│ └──────────┘ │ - 名称/距离/时间 │ │
│ └────────────────────┘ │
└─────────────────────────────────────────┘
2.4.2 数据流
用户操作 → NavService.findNearestPOI() → HospitalData.getPOIs()
↓
距离计算+楼层过滤
↓
用户位置 → NavService.calculatePath() → HospitalData.getPaths()
↓
路径查询
↓
NavService.estimateWalkTime() → 时间估算
↓
HospitalMapView.render() ← 所有数据 ← Canvas绘制
2.4.3 MVP功能边界
| 功能 | MVP | 后续版本 |
|---|---|---|
| 地图显示 | Canvas静态平面图 | MapKit+室内地图 |
| 定位方式 | 手动选择楼层 | BLE/WiFi自动定位 |
| 路径规划 | 直线两点路径 | A*/Dijkstra最短路径 |
| 路径引导 | 地图上显示路径 | 实时转弯提示+语音 |
| POI搜索 | 按类型快捷搜索 | 全文搜索+分类浏览 |
| 楼层切换 | Tab手动切换 | 自动楼层检测 |
3. NavService导航服务(40行代码)
NavService是室内导航系统的核心服务类,提供POI搜索、路径计算和步行时间估算三大核心功能。该服务以静态工具类的形式实现,所有方法均为静态方法,无需实例化。
3.1 类设计概览
export class NavService {
static findNearestPOI(userX: number, userY: number, floor: number, type: string): HospitalPOI | undefined
static calculatePath(from: HospitalPOI, to: HospitalPOI): string[]
static estimateWalkTime(from: HospitalPOI, to: HospitalPOI): number
}
三个核心方法的职责划分:
| 方法 | 输入 | 输出 | 职责 |
|---|---|---|---|
| findNearestPOI | 用户坐标+楼层+类型 | 最近POI或undefined | 空间搜索 |
| calculatePath | 起点POI+终点POI | 路径节点ID数组 | 路径规划 |
| estimateWalkTime | 起点POI+终点POI | 步行时间(分钟) | 时间估算 |
3.2 findNearestPOI — 欧几里得距离搜索
3.2.1 完整实现
static findNearestPOI(userX: number, userY: number, floor: number, type: string): HospitalPOI | undefined {
const pois = HospitalData.getPOIs()
let nearest: HospitalPOI | undefined = undefined
let minDist = Number.MAX_VALUE
for (let i = 0; i < pois.length; i++) {
const poi = pois[i]
if (poi.floor !== floor || poi.type !== type) continue
const dx = userX - poi.x
const dy = userY - poi.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < minDist) { minDist = dist; nearest = poi }
}
return nearest
}
3.2.2 算法详解
第一步:获取POI数据集
const pois = HospitalData.getPOIs()
从HospitalData获取所有POI的完整列表。当前MVP版本中,数据量较小(8个POI),直接获取全量数据在内存中进行过滤。后续版本数据量增大时,可考虑按楼层建立索引。
第二步:初始化搜索状态
let nearest: HospitalPOI | undefined = undefined
let minDist = Number.MAX_VALUE
使用Number.MAX_VALUE作为初始最小距离,确保第一个满足条件的POI一定能成为当前最近点。nearest初始化为undefined,当没有满足条件的POI时,函数自然返回undefined。
第三步:楼层过滤+类型过滤
if (poi.floor !== floor || poi.type !== type) continue
这是双重过滤条件:
poi.floor !== floor:只搜索用户当前所在楼层的POI。跨楼层导航需要在路径计算中处理电梯/楼梯节点,MVP版本暂不支持poi.type !== type:只搜索指定类型的POI。类型包括:nurse(护士站)、infusion(输液室)、emergency(急诊)、pharmacy(药房)、inpatient(住院部)
使用continue跳过不满足条件的POI,避免深层嵌套的if-else结构。
第四步:欧几里得距离计算
const dx = userX - poi.x
const dy = userY - poi.y
const dist = Math.sqrt(dx * dx + dy * dy)
欧几里得距离公式:
d = √((x₁ - x₂)² + (y₁ - y₂)²)
在当前坐标系中,x和y的单位是像素(px),对应地图上的位置。由于地图是等比例绘制的,像素距离与实际距离成正比关系。
第五步:更新最近点
if (dist < minDist) { minDist = dist; nearest = poi }
采用标准的线性搜索最小值模式。每次发现更近的POI时,同时更新最小距离和最近点引用。
3.2.3 算法复杂度分析
| 指标 | 值 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | n为POI总数,需要遍历所有POI |
| 空间复杂度 | O(1) | 仅使用常量额外空间 |
当前n=8,O(n)的线性搜索完全足够。当POI数量增长到数百甚至数千时,可以考虑以下优化方案:
空间索引优化:
-
网格索引:将地图空间划分为等大小的网格,每个网格维护其中的POI列表。搜索时只查询用户所在网格及相邻网格的POI,将搜索范围从O(n)降低到O(1)(假设网格内POI数量有界)。
-
KD-Tree:构建二维KD-Tree索引,最近邻查询的时间复杂度为O(log n)。适合POI数量大且查询频繁的场景。
-
按楼层分组:将POI按楼层分组存储,搜索时直接定位到目标楼层组,减少遍历数量。
网格索引示例(20×20网格):
┌──┬──┬──┬──┬──┐
│ │ │N1│ │ │ N1 = 护士站1F
│ │ │ │ │ │ I1 = 输液室A
├──┼──┼──┼──┼──┤ I2 = 输液室B
│ │I1│ │I2│ │ E1 = 急诊室
│ │ │ │ │ │ P1 = 药房
├──┼──┼──┼──┼──┤
│E1│ │ │ │P1│
│ │ │ │ │ │
└──┴──┴──┴──┴──┘
搜索(150, 250)附近输液室:
→ 查询网格(1,2)及相邻8个网格
→ 仅需检查3个POI而非8个
3.2.4 边界情况处理
| 场景 | 处理方式 | 返回值 |
|---|---|---|
| 指定楼层无POI | 循环中无匹配项 | undefined |
| 指定类型无POI | 类型过滤全部跳过 | undefined |
| 多个POI距离相同 | 返回遍历顺序中第一个 | 先定义的POI |
| 用户坐标为负数 | 数学计算正常 | 正常结果 |
| 用户坐标超出地图 | 距离较大但仍可计算 | 正常结果 |
调用方应当检查返回值是否为undefined:
const nearestNurse = NavService.findNearestPOI(userX, userY, 1, 'nurse')
if (nearestNurse) {
// 找到了最近的护士站
} else {
// 当前楼层没有护士站
}
3.3 calculatePath — 路径计算
3.3.1 完整实现
static calculatePath(from: HospitalPOI, to: HospitalPOI): string[] {
const key = `${from.id}->${to.id}`
const reverseKey = `${to.id}->${from.id}`
const paths = HospitalData.getPaths()
if (paths[key]) {
return paths[key]
}
if (paths[reverseKey]) {
return paths[reverseKey].slice().reverse()
}
return [from.id, to.id]
}
3.3.2 算法详解
第一步:构建路径键
const key = `${from.id}->${to.id}`
const reverseKey = `${to.id}->${from.id}`
路径键格式为fromId->toId,使用模板字符串构建。同时构建反向键,用于检查是否存在反向路径。
第二步:查表获取路径
const paths = HospitalData.getPaths()
if (paths[key]) {
return paths[key]
}
从HospitalData获取预定义的路径字典,直接查表。如果正向路径存在,直接返回。
第三步:反向路径处理
if (paths[reverseKey]) {
return paths[reverseKey].slice().reverse()
}
如果正向路径不存在但反向路径存在,则将反向路径翻转后返回。这实现了路径的双向性:如果A→B有路径,则B→A也有路径。
slice()先创建数组副本,再reverse()翻转,避免修改原始数据。
第四步:兜底直连路径
return [from.id, to.id]
如果预定义路径中既没有正向路径也没有反向路径,返回仅包含起点和终点的直连路径。这保证了函数始终返回有效结果,不会返回空数组。
3.3.3 路径表示格式
路径以POI ID数组的形式表示:
// 直连路径
['poi_infusion_1f', 'poi_nurse_1f']
// 经过中间节点的路径(未来版本)
['poi_infusion_1f', 'waypoint_hall_1f', 'poi_nurse_1f']
路径数组的含义:
path[0]: 起点POI IDpath[path.length-1]: 终点POI IDpath[1..n-2]: 中间途径点POI ID(可选)- 相邻元素之间表示一段可通行的路径
3.3.4 MVP限制与未来演进
| 版本 | 算法 | 路径质量 | 计算复杂度 |
|---|---|---|---|
| MVP | 直连+查表 | 两点直线 | O(1) |
| v1.1 | Dijkstra | 最短路径 | O((V+E)logV) |
| v1.2 | A* | 启发式最短路径 | O(b^d) |
| v2.0 | 分层路径规划 | 多楼层最优路径 | O(N·logN) |
A*算法预研:
A*算法是路径规划的经典算法,其核心公式为:
f(n) = g(n) + h(n)
其中:
- f(n): 节点n的总评估代价
- g(n): 从起点到节点n的实际代价
- h(n): 从节点n到终点的启发式估计代价
对于室内2D网格地图,启发函数h(n)通常使用曼哈顿距离或欧几里得距离:
function heuristic(from: Waypoint, to: Waypoint): number {
return Math.sqrt(
Math.pow(from.x - to.x, 2) +
Math.pow(from.y - to.y, 2)
)
}
Dijkstra算法预研:
Dijkstra算法适用于权重图中寻找单源最短路径。对于室内导航,图模型的构建方式:
节点 = POI + 途径点(走廊交叉点、门口等)
边 = 可通行的路径段
权重 = 实际步行距离(考虑走廊、楼梯、电梯等)
interface GraphNode {
id: string
x: number
y: number
floor: number
type: 'poi' | 'waypoint' | 'elevator' | 'stairs'
}
interface GraphEdge {
from: string
to: string
weight: number // 步行距离(米)
type: 'corridor' | 'elevator' | 'stairs'
}
3.4 estimateWalkTime — 步行时间估算
3.4.1 完整实现
static estimateWalkTime(from: HospitalPOI, to: HospitalPOI): number {
const dx = from.x - to.x
const dy = from.y - to.y
const dist = Math.sqrt(dx * dx + dy * dy)
const walkSpeed = 80 // 80像素/分钟
const minutes = Math.ceil(dist / walkSpeed)
return minutes > 0 ? minutes : 1
}
3.4.2 步行速度假设分析
const walkSpeed = 80 // 80像素/分钟
这个速度假设基于以下换算:
- 地图比例:当前地图400px × 400px对应约40m × 40m的实际空间
- 比例尺:1px ≈ 0.1m = 10cm
- 普通人步行速度:约80m/分钟(4.8km/h)
- 像素速度:80m/min ÷ 0.1m/px = 800px/min
当前代码中的80像素/分钟实际上偏低,对应实际步行速度约8m/分钟(0.48km/h),这相当于非常缓慢的步行速度。但考虑到以下因素,这个估值是合理的:
- 医院环境:患者通常步行速度较慢,尤其输液患者
- 走廊曲折:实际步行路径不是直线,需要绕行
- 等待时间:电梯等待、门禁通过等
- 方向辨认:在陌生环境中需要停下来辨别方向
更精确的步行时间估算模型应考虑:
| 因素 | 影响系数 | 说明 |
|---|---|---|
| 患者状态 | ×0.3-0.8 | 输液患者步行速度约为常人的30-80% |
| 年龄 | ×0.6-1.0 | 老年患者步行速度下降 |
| 楼层变换 | +2-5min | 电梯等待+乘坐时间 |
| 拥挤程度 | ×1.0-1.5 | 门诊高峰期走廊拥挤 |
| 路径复杂度 | ×1.0-1.3 | 转弯次数多则实际耗时增加 |
3.4.3 最小时间保证
return minutes > 0 ? minutes : 1
使用三元表达式确保返回值至少为1分钟。这在以下情况下发挥作用:
- 两个POI距离非常近(< 80px)
Math.ceil向上取整后为0(理论上不可能,因为dist > 0时dist/80 > 0,Math.ceil结果≥1)- 起点和终点相同时,
dist = 0,Math.ceil(0) = 0,此时返回1
这个最小时间保证的语义是:即使两点非常近,也至少需要1分钟的步行时间。这在UI展示上更合理——显示"0分钟"会让用户误以为不需要走路。
3.4.4 精度提升方案
当前版本使用简单的欧几里得距离除以恒定速度,未来版本可以从以下维度提升精度:
路径距离代替直线距离:
static estimateWalkTimeWithPath(path: string[]): number {
let totalDist = 0
const pois = HospitalData.getPOIs()
for (let i = 0; i < path.length - 1; i++) {
const from = pois.find(p => p.id === path[i])
const to = pois.find(p => p.id === path[i + 1])
if (from && to) {
totalDist += Math.sqrt(
Math.pow(from.x - to.x, 2) + Math.pow(from.y - to.y, 2)
)
}
}
return Math.max(1, Math.ceil(totalDist / walkSpeed))
}
分段速度模型:
不同路径段使用不同的步行速度:
- 走廊段:80 px/min(正常步行)
- 楼梯段:40 px/min(上下楼速度较慢)
- 电梯段:固定等待时间+运行时间
- 拥挤区域:速度降低30%
4. HospitalData空间数据模型(38行代码)
HospitalData是室内导航系统的空间数据核心,定义了医院内部的POI(Point of Interest,兴趣点)、路径网络和地图参数。所有导航计算都依赖于这个数据模型。
4.1 数据类型定义
4.1.1 HospitalPOI类型
export interface HospitalPOI {
id: string // 唯一标识符,如 'poi_nurse_1f'
name: string // 显示名称,如 '护士站'
type: string // POI类型:nurse/infusion/emergency/pharmacy/inpatient
floor: number // 所在楼层:1/2/3
x: number // X坐标(px),地图左上角为原点
y: number // Y坐标(px),地图左上角为原点
color: string // 显示颜色,如 '#4CAF50'
}
字段详解:
| 字段 | 类型 | 用途 | 示例 |
|---|---|---|---|
| id | string | 唯一标识,用于路径引用 | poi_nurse_1f |
| name | string | UI显示名称 | 护士站 |
| type | string | 类型过滤和颜色映射 | nurse |
| floor | number | 楼层过滤 | 1 |
| x | number | 地图水平坐标 | 200 |
| y | number | 地图垂直坐标 | 150 |
| color | string | Canvas渲染颜色 | #4CAF50 |
ID命名规范:poi_{类型简称}_{楼层},例如:
poi_nurse_1f:1楼护士站poi_infusion_1f:1楼输液室Apoi_infusion_b_1f:1楼输液室Bpoi_emergency:急诊室(隐含1楼)poi_pharmacy:药房(隐含1楼)poi_inpatient_2f:2楼住院部
4.2 POI数据(8个兴趣点)
4.2.1 完整POI列表
static getPOIs(): HospitalPOI[] {
return [
{ id: 'poi_nurse_1f', name: '护士站', type: 'nurse', floor: 1, x: 200, y: 150, color: '#4CAF50' },
{ id: 'poi_infusion_1f', name: '输液室A', type: 'infusion', floor: 1, x: 100, y: 300, color: '#2196F3' },
{ id: 'poi_infusion_b_1f', name: '输液室B', type: 'infusion', floor: 1, x: 300, y: 300, color: '#2196F3' },
{ id: 'poi_emergency', name: '急诊室', type: 'emergency', floor: 1, x: 50, y: 50, color: '#F44336' },
{ id: 'poi_pharmacy', name: '药房', type: 'pharmacy', floor: 1, x: 350, y: 100, color: '#FF9800' },
{ id: 'poi_nurse_2f', name: '护士站', type: 'nurse', floor: 2, x: 200, y: 150, color: '#4CAF50' },
{ id: 'poi_inpatient_2f', name: '住院部', type: 'inpatient', floor: 2, x: 100, y: 200, color: '#9C27B0' },
{ id: 'poi_inpatient_3f', name: '住院部', type: 'inpatient', floor: 3, x: 100, y: 200, color: '#9C27B0' }
]
}
4.2.2 POI空间分布图
1楼布局(5个POI):
0 50 100 150 200 250 300 350 400
0 ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ │ │ │ │ │ │ │ │
50 │ 🔴急诊│ │ │ │ │ │ │ 🟠药房│
│ │ │ │ │ │ │ │ │
100 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
150 │ │ │ │ │ 🟢护士站│ │ │ │
│ │ │ │ │ │ │ │ │
200 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
250 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
300 │ │ │ 🔵输液A│ │ │ │ 🔵输液B│ │
│ │ │ │ │ │ │ │ │
350 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
400 └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
图例: 🟢护士站 🔵输液室 🔴急诊 🟠药房

2楼布局(2个POI):
0 50 100 150 200 250 300 350 400
0 ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ │ │ │ │ │ │ │ │
50 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
100 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
150 │ │ │ │ │ 🟢护士站│ │ │ │
│ │ │ │ │ │ │ │ │
200 │ │ │ 🟣住院部│ │ │ │ │ │
│ │ │ │ │ │ │ │ │
250 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
300 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
350 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
400 └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
图例: 🟢护士站 🟣住院部
3楼布局(1个POI):
0 50 100 150 200 250 300 350 400
0 ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ │ │ │ │ │ │ │ │
50 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
100 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
150 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
200 │ │ │ 🟣住院部│ │ │ │ │ │
│ │ │ │ │ │ │ │ │
250 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
300 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
350 │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │
400 └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
图例: 🟣住院部
4.2.3 颜色编码体系
| POI类型 | 颜色 | 色值 | 语义 |
|---|---|---|---|
| 护士站 (nurse) | 绿色 | #4CAF50 | 安全、求助、医护 |
| 输液室 (infusion) | 蓝色 | #2196F3 | 治疗、专业 |
| 急诊室 (emergency) | 红色 | #F44336 | 紧急、危险 |
| 药房 (pharmacy) | 橙色 | #FF9800 | 取药、注意 |
| 住院部 (inpatient) | 紫色 | #9C27B0 | 住院、长期 |
颜色选择遵循以下原则:
- 国际惯例:红色=紧急/危险,绿色=安全/医护,与医疗行业标准一致
- 可辨识度:5种颜色在色轮上均匀分布,色差明显,不会混淆
- 无障碍:所有颜色饱和度适中,在浅色背景上对比度足够
- 语义映射:颜色与POI类型的语义关联自然,降低用户认知负担
4.2.4 POI坐标系统
当前坐标系使用像素坐标,原点在地图左上角:
(0,0) ──────────── X轴 ────────────→ (400,0)
│ │
│ │
Y轴 │
│ │
↓ │
(0,400) ──────────────────────── (400,400)
坐标与实际距离的对应关系(假设比例尺1px=0.1m):
| POI | 坐标(px) | 估算实际位置(m) |
|---|---|---|
| 护士站1F | (200, 150) | (20, 15) |
| 输液室A | (100, 300) | (10, 30) |
| 输液室B | (300, 300) | (30, 30) |
| 急诊室 | (50, 50) | (5, 5) |
| 药房 | (350, 100) | (35, 10) |
4.3 路径网络
4.3.1 路径数据定义
static getPaths(): Record<string, string[]> {
const paths: Record<string, string[]> = {}
paths['poi_infusion_1f->poi_nurse_1f'] = ['poi_infusion_1f', 'poi_nurse_1f']
paths['poi_infusion_b_1f->poi_nurse_1f'] = ['poi_infusion_b_1f', 'poi_nurse_1f']
paths['poi_emergency->poi_nurse_1f'] = ['poi_emergency', 'poi_nurse_1f']
paths['poi_pharmacy->poi_nurse_1f'] = ['poi_pharmacy', 'poi_nurse_1f']
paths['poi_nurse_1f->poi_infusion_1f'] = ['poi_nurse_1f', 'poi_infusion_1f']
return paths
}
4.3.2 路径网络拓扑图
1楼路径网络:
急诊室 ──────────── 护士站 ──────────── 药房
(50,50) (200,150) (350,100)
│
│
┌────────┴────────┐
│ │
输液室A 输液室B
(100,300) (300,300)
路径列表:
1. 急诊室 → 护士站
2. 药房 → 护士站
3. 输液室A → 护士站
4. 输液室B → 护士站
5. 护士站 → 输液室A
路径网络的特点:
- 星型拓扑:1楼护士站是中心节点,所有路径都经过护士站
- 5条预定义路径:覆盖了IVGuard场景下最常用的行走路线
- 以护士站为中心:体现了护士站在医院运营中的枢纽地位
4.3.3 键格式规范
paths['fromId->toId'] = ['fromId', 'toId', ...]
键(Key)的格式为fromId->toId,使用->作为分隔符(而非-,避免与POI ID中的-混淆)。值的类型为string[],即POI ID数组,表示从起点到终点依次经过的节点序列。
键的设计考量:
- 可读性:
poi_infusion_1f->poi_nurse_1f一目了然地表示路径方向 - 双向性:正向键和反向键分别存储,
calculatePath方法会同时检查两个方向 - 扩展性:中间途径点直接在数组中添加,键格式不需要修改
4.3.4 路径数据扩展方案
当前5条路径仅覆盖1楼的基本连通。后续版本需要扩展:
增加途径点:
paths['poi_emergency->poi_infusion_1f'] = [
'poi_emergency',
'waypoint_corridor_1f_1',
'poi_nurse_1f',
'waypoint_corridor_1f_2',
'poi_infusion_1f'
]
增加跨楼层路径:
paths['poi_infusion_1f->poi_inpatient_2f'] = [
'poi_infusion_1f',
'waypoint_elevator_1f',
'elevator_1f_to_2f',
'waypoint_elevator_2f',
'poi_inpatient_2f'
]
路径权重:
interface WeightedPath {
nodes: string[]
distance: number // 总距离(米)
time: number // 预计时间(分钟)
obstacles: string[] // 障碍物(楼梯、门禁等)
}
4.4 地图参数
static getFloorCount(): number { return 3 }
static getMapWidth(): number { return 400 }
static getMapHeight(): number { return 400 }
| 参数 | 值 | 说明 |
|---|---|---|
| 楼层数 | 3 | 1F/2F/3F三层 |
| 地图宽度 | 400px | Canvas绘制宽度 |
| 地图高度 | 400px | Canvas绘制高度 |
地图尺寸选择400×400px的原因:
- 正方形布局:便于在不同屏幕尺寸上等比缩放
- 适中分辨率:在手机屏幕上足够清晰,同时不会造成Canvas绘制性能问题
- 坐标范围友好:0-400的范围便于手动设置POI坐标
- 设备适配:通过Canvas的缩放能力适配不同屏幕密度
4.4.1 屏幕适配策略
逻辑坐标(0-400) → Canvas坐标系 → 屏幕像素坐标
示例(设备像素比2.0):
逻辑坐标 (200, 150)
→ Canvas坐标 (200, 150)
→ 屏幕像素 (400, 300)
HarmonyOS的Canvas组件自动处理设备像素比(DPR),开发者只需使用逻辑坐标即可。
4.5 数据层架构
┌──────────────────────────────────┐
│ UI层 (HospitalNavPage) │
├──────────────────────────────────┤
│ 服务层 (NavService) │
│ findNearestPOI / calculatePath │
├──────────────────────────────────┤
│ 数据层 (HospitalData) │
│ getPOIs / getPaths / getMap* │
├──────────────────────────────────┤
│ 渲染层 (HospitalMapView) │
│ Canvas 2D绘制 │
└──────────────────────────────────┘
HospitalData作为数据层,向上提供只读的数据访问接口,不包含任何业务逻辑。这种分层设计的好处:
- 数据与逻辑解耦:修改POI数据不需要修改导航算法
- 数据源可替换:未来可以从本地JSON文件或远程API加载数据
- 便于测试:可以注入测试数据验证导航算法
- 职责单一:每个类的职责清晰,便于维护
5. HospitalMapView Canvas渲染(97行代码)
HospitalMapView是室内导航系统的可视化组件,负责将空间数据渲染为2D平面图。该组件基于HarmonyOS的Canvas组件实现,采用声明式渲染模式。
5.1 组件结构概览
@Component
export struct HospitalMapView {
@Prop currentFloor: number = 1
@Prop userX: number = 200
@Prop userY: number = 150
@Prop targetPOI: HospitalPOI | undefined = undefined
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: RenderingContext = new RenderingContext(this.settings)
build() {
Canvas(this.context)
.width('100%')
.height(400)
.onReady(() => {
this.renderMap()
})
}
private renderMap() { ... }
private drawBackground() { ... }
private drawRooms() { ... }
private drawPOIs() { ... }
private drawPath() { ... }
private drawUserPosition() { ... }
}
5.2 2D平面图绘制算法
5.2.1 背景色绘制
private drawBackground() {
this.context.fillStyle = '#F5F5F5'
this.context.fillRect(0, 0, 400, 400)
}
选择#F5F5F5作为背景色的原因:
- 浅灰色而非纯白色,减少视觉疲劳
- 与白色墙壁和彩色POI标记形成柔和对比
- 模拟医院室内的干净、明亮氛围
5.2.2 墙壁绘制
private drawRooms() {
const ctx = this.context
ctx.strokeStyle = '#333333'
ctx.lineWidth = 2
// 外墙边界
ctx.strokeRect(10, 10, 380, 380)
// 房间1:输液区域
ctx.fillStyle = '#E8F5E9'
ctx.fillRect(50, 250, 300, 100)
// 房间2:护士站区域
ctx.fillStyle = '#E3F2FD'
ctx.fillRect(150, 100, 100, 100)
// 房间3:急诊区域
ctx.fillStyle = '#FFEBEE'
ctx.fillRect(20, 20, 80, 80)
}
绘制层次:
- 外墙:
strokeRect(10, 10, 380, 380)— 描边2px的深灰色矩形,表示建筑外围 - 房间填充:每个房间使用淡色填充,颜色与该区域POI颜色同色系但更浅
- 语义关联:房间填充色与POI标记色系一致,建立视觉关联
房间颜色映射:
| 房间 | 填充色 | 对应POI色 | 色系 |
|---|---|---|---|
| 输液区域 | #E8F5E9 | #2196F3 | 蓝色系(实际使用绿色系浅色) |
| 护士站区域 | #E3F2FD | #4CAF50 | 绿色系(实际使用蓝色系浅色) |
| 急诊区域 | #FFEBEE | #F44336 | 红色系 |
注意:当前代码中房间填充色与POI标记色的色系映射存在不一致,这是MVP阶段的小问题,后续版本应统一。
5.2.3 3个房间矩形详解
房间1:输液区域
ctx.fillStyle = '#E8F5E9'
ctx.fillRect(50, 250, 300, 100)
- 位置:(50, 250)
- 尺寸:300×100px
- 横跨地图大部分宽度,位于底部
- 包含两个POI:输液室A(100, 300)和输液室B(300, 300)
- 对应实际:一个大的开放输液区,内有A、B两个输液区
房间2:护士站区域
ctx.fillStyle = '#E3F2FD'
ctx.fillRect(150, 100, 100, 100)
- 位置:(150, 100)
- 尺寸:100×100px
- 位于地图中央偏上
- 包含POI:护士站(200, 150)
- 对应实际:护士站办公区域,位于楼层中心便于响应各处呼叫
房间3:急诊区域
ctx.fillStyle = '#FFEBEE'
ctx.fillRect(20, 20, 80, 80)
- 位置:(20, 20)
- 尺寸:80×80px
- 位于地图左上角
- 包含POI:急诊室(50, 50)
- 对应实际:急诊室通常位于1楼入口附近,便于急救车辆到达
5.3 POI标记渲染
5.3.1 渲染流程
private drawPOIs() {
const pois = HospitalData.getPOIs()
const ctx = this.context
for (let i = 0; i < pois.length; i++) {
const poi = pois[i]
if (poi.floor !== this.currentFloor) continue
this.drawSinglePOI(poi)
}
}
遍历所有POI,仅绘制当前楼层的POI。楼层过滤确保不同楼层的标记不会重叠显示。
5.3.2 单个POI绘制
private drawSinglePOI(poi: HospitalPOI) {
const ctx = this.context
// 颜色编码
ctx.fillStyle = poi.color
// 圆形标记
ctx.beginPath()
ctx.arc(poi.x, poi.y, 8, 0, 2 * Math.PI)
ctx.fill()
// 文字标注
ctx.fillStyle = '#333333'
ctx.font = '12px sans-serif'
ctx.fillText(poi.name, poi.x - 15, poi.y + 20)
}
颜色编码:
ctx.fillStyle = poi.color
直接使用POI对象中的color字段,该字段在数据定义时已根据类型预设。颜色映射由getPOIColor(type)函数或数据定义层完成:
static getPOIColor(type: string): string {
const colorMap: Record<string, string> = {
'nurse': '#4CAF50',
'infusion': '#2196F3',
'emergency': '#F44336',
'pharmacy': '#FF9800',
'inpatient': '#9C27B0'
}
return colorMap[type] || '#999999'
}
圆形标记:
ctx.arc(poi.x, poi.y, 8, 0, 2 * Math.PI)
参数解析:
poi.x, poi.y:圆心坐标,即POI位置8:半径8px,在400×400的地图上大小适中0, 2π:起始角0,结束角2π,即完整圆- 使用实心填充(
fill())而非描边,确保标记醒目
文字标注:
ctx.fillText(poi.name, poi.x - 15, poi.y + 20)
- 字体:12px无衬线字体,清晰易读
- 颜色:深灰色
#333333,在浅色背景上对比度足够 - 位置:圆形标记下方偏右
- 水平偏移-15px:文字左移15px,使中文字符大致居中于圆形下方
- 垂直偏移+20px:从圆心下移20px(圆半径8px + 间距12px),避免与圆形重叠
5.3.3 文字偏移量计算
圆心(poi.x, poi.y)
↓
● ← 8px半径圆形标记
│
│ 12px间距
│
文字基线 ← (poi.x - 15, poi.y + 20)
↓
POI名称文字
fillText的y坐标是文字基线位置,不是文字顶部。对于12px字体,基线到文字顶部约10px。因此,文字视觉上出现在圆形下方约2px处(20 - 8 - 10 = 2),看起来紧贴圆形下方。
5.4 导航路径绘制
5.4.1 路径渲染实现
private drawPath() {
if (!this.targetPOI) return
const ctx = this.context
const path = NavService.calculatePath(
{ id: 'user', name: '', type: '', floor: this.currentFloor, x: this.userX, y: this.userY, color: '' },
this.targetPOI
)
ctx.strokeStyle = '#4CAF50'
ctx.lineWidth = 2
ctx.setLineDash([5, 3])
ctx.beginPath()
ctx.moveTo(this.userX, this.userY)
ctx.lineTo(this.targetPOI.x, this.targetPOI.y)
ctx.stroke()
ctx.setLineDash([])
}
5.4.2 虚线样式详解
ctx.setLineDash([5, 3])
setLineDash参数是一个数组,表示交替的线段长度和间隔长度:
[5, 3]:5px实线 + 3px间隔 + 5px实线 + 3px间隔 + …- 这种短虚线样式在地图上常用于表示导航路径
- 虚线与实线(墙壁)视觉区分明显
绘制完成后重置虚线:
ctx.setLineDash([])
必须重置,否则后续绘制的所有线条都会变成虚线。
5.4.3 路径颜色
ctx.strokeStyle = '#4CAF50'
使用绿色#4CAF50作为路径颜色,与护士站的绿色一致。选择绿色的原因:
- 导航语义:绿色表示"通行"、“安全路线”
- 与墙壁区分:灰色墙壁 vs 绿色路径,对比明显
- 与背景区分:浅灰背景上的绿色线条清晰可见
- 与POI标记区分:路径是线条,POI是圆形,形状不同
5.4.4 路径绘制流程
用户位置(userX, userY)
│
│ moveTo()
│
│ 绿色虚线
│ ┊┊┊┊┊┊┊┊
│
│ lineTo()
│
目标POI(targetPOI.x, targetPOI.y)
当前版本只绘制从用户位置到目标POI的直线。如果calculatePath返回的路径包含中间途径点,需要依次连接所有节点:
// 未来版本的多段路径绘制
ctx.beginPath()
ctx.moveTo(this.userX, this.userY)
for (const nodeId of path) {
const node = findNodeById(nodeId)
ctx.lineTo(node.x, node.y)
}
ctx.stroke()
5.5 用户位置蓝点
private drawUserPosition() {
const ctx = this.context
ctx.fillStyle = '#2196F3'
ctx.beginPath()
ctx.arc(this.userX, this.userY, 6, 0, 2 * Math.PI)
ctx.fill()
ctx.fillStyle = '#1565C0'
ctx.font = '11px sans-serif'
ctx.fillText('您在这里', this.userX - 20, this.userY - 12)
}
蓝色圆点:
ctx.fillStyle = '#2196F3'
ctx.arc(this.userX, this.userY, 6, 0, 2 * Math.PI)
- 颜色:蓝色
#2196F3,与POI标记的任何颜色都不重复 - 半径:6px,比POI标记(8px)略小,表示这是一个特殊标记而非POI
- 填充:实心蓝色圆点
文字标注:
ctx.fillStyle = '#1565C0'
ctx.fillText('您在这里', this.userX - 20, this.userY - 12)
- 颜色:深蓝色
#1565C0,比圆点颜色更深,文字更清晰 - 位置:圆点上方12px处(
y - 12),不遮挡圆点 - 水平偏移-20px:中文字符居中
用户位置标记 vs POI标记对比:
| 属性 | 用户位置 | POI标记 |
|---|---|---|
| 颜色 | 蓝色(#2196F3) | 类型相关色 |
| 半径 | 6px | 8px |
| 文字 | “您在这里” | POI名称 |
| 文字位置 | 上方 | 下方 |
| 文字颜色 | 深蓝 | 深灰 |
| 绘制顺序 | 最上层 | 中间层 |
绘制顺序确保用户位置蓝点始终可见,不被路径线或POI标记遮挡。
5.6 楼层切换渲染
@Prop currentFloor: number = 1
currentFloor作为组件的属性(@Prop),由父组件HospitalNavPage传入。当楼层切换时,currentFloor值变化触发组件重新渲染:
drawPOIs()中的楼层过滤:if (poi.floor !== this.currentFloor) continuedrawPath()中使用this.currentFloor构建临时POI对象drawUserPosition()使用this.userX和this.userY,坐标值在不同楼层可能不同
楼层切换的渲染流程:
用户点击2F Tab
↓
HospitalNavPage: currentFloor = 2
↓
@Prop 传递: HospitalMapView.currentFloor = 2
↓
onReady → renderMap() 重新执行
↓
drawPOIs(): 只绘制floor=2的POI(护士站2F、住院部2F)
↓
1F的5个POI不再显示,2F的2个POI出现
5.6.1 渲染顺序与图层叠加
renderMap()方法中的绘制顺序决定了各图层的叠加关系:
private renderMap() {
this.drawBackground() // 第1层:背景
this.drawRooms() // 第2层:房间矩形
this.drawPOIs() // 第3层:POI标记
this.drawPath() // 第4层:导航路径
this.drawUserPosition() // 第5层:用户位置(最上层)
}
绘制顺序从底层到顶层,后绘制的内容覆盖先绘制的内容:
┌─────────────────────────────────────┐
│ 第5层: 用户位置蓝点 (最上层) │
├─────────────────────────────────────┤
│ 第4层: 导航路径虚线 │
├─────────────────────────────────────┤
│ 第3层: POI圆形标记 + 文字 │
├─────────────────────────────────────┤
│ 第2层: 房间填充色 │
├─────────────────────────────────────┤
│ 第1层: #F5F5F5背景 (最底层) │
└─────────────────────────────────────┘
这个顺序确保:
- 用户位置蓝点永远不会被其他元素遮挡
- 导航路径虚线在POI标记之上,清晰可见
- POI标记在房间填充之上,不被背景吞没
- 房间填充在纯色背景之上,区域划分明显
5.6.2 Canvas性能优化
当前400×400px的Canvas在每次楼层切换时完整重绘,对于8个POI的简单场景性能完全足够。当数据规模增大时,可以考虑以下优化:
脏矩形重绘:
只重绘发生变化的区域,而非整个画布。例如,当只有用户位置移动时,只需清除旧位置蓝点区域和路径区域,重绘这两个图层。
离屏Canvas缓存:
将静态内容(背景、房间、POI标记)绘制到离屏Canvas,楼层切换时只需将离屏Canvas复制到主Canvas,再叠加动态内容(路径、用户位置)。
// 离屏Canvas缓存方案(伪代码)
private offscreenCanvas: ImageData | null = null
private renderStaticLayer() {
// 绘制背景、房间、POI到离屏Canvas
this.offscreenCanvas = this.context.getImageData(0, 0, 400, 400)
}
private renderMap() {
if (this.offscreenCanvas) {
this.context.putImageData(this.offscreenCanvas, 0, 0)
} else {
this.renderStaticLayer()
}
this.drawPath()
this.drawUserPosition()
}
requestAnimationFrame:
对于用户位置实时更新的场景,使用requestAnimationFrame控制绘制帧率,避免过度渲染。
5.7 Canvas API使用总结
HospitalMapView使用的Canvas 2D API汇总:
| API | 用途 | 出现位置 |
|---|---|---|
fillRect |
矩形填充 | 背景、房间 |
strokeRect |
矩形描边 | 外墙边界 |
beginPath |
开始路径 | 圆形、路径线 |
arc |
圆弧绘制 | POI标记、用户位置 |
fill |
填充路径 | 圆形填充 |
stroke |
描边路径 | 路径线、墙壁 |
moveTo |
移动画笔 | 路径起点 |
lineTo |
画线到点 | 路径终点 |
fillText |
文字绘制 | POI名称、用户标注 |
fillStyle |
填充颜色 | 各种填充 |
strokeStyle |
描边颜色 | 路径线、墙壁 |
lineWidth |
线宽 | 墙壁2px、路径2px |
setLineDash |
虚线设置 | 导航路径 |
font |
字体设置 | 文字标注 |
6. HospitalNavPage交互设计
HospitalNavPage是室内导航系统的页面级组件,整合了地图渲染、楼层切换、快捷导航和目的地信息展示等功能。
6.1 页面布局架构
┌─────────────────────────────────────┐
│ HospitalNavPage │
│ │
│ ┌─────────────────────────────┐ │
│ │ 楼层Tab切换 │ │
│ │ [1F] [2F] [3F] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ HospitalMapView │ │
│ │ Canvas地图渲染区域 │ │
│ │ (400 × 400px) │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 快捷导航按钮 │ │
│ │ [护士站] [输液室] [急诊] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 目的地信息卡片 │ │
│ │ ┌───────┬──────────────┐ │ │
│ │ │ 图标 │ 护士站 │ │ │
│ │ │ │ 1楼 · 步行3分 │ │ │
│ │ └───────┴──────────────┘ │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
6.2 楼层Tab切换
6.2.1 实现方式
使用HarmonyOS的Tabs组件实现楼层切换:
@State currentFloor: number = 1
Tabs({ barPosition: BarPosition.Start }) {
TabContent() {
HospitalMapView({ currentFloor: this.currentFloor, userX: 200, userY: 150, targetPOI: this.targetPOI })
}.tabBar('1F')
TabContent() {
HospitalMapView({ currentFloor: this.currentFloor, userX: 200, userY: 150, targetPOI: this.targetPOI })
}.tabBar('2F')
TabContent() {
HospitalMapView({ currentFloor: this.currentFloor, userX: 200, userY: 150, targetPOI: this.targetPOI })
}.tabBar('3F')
}
.onChange((index: number) => {
this.currentFloor = index + 1
})
6.2.2 楼层切换逻辑
用户点击Tab "2F"
↓
onChange回调: index = 1
↓
currentFloor = 1 + 1 = 2
↓
HospitalMapView通过@Prop接收currentFloor=2
↓
Canvas重新渲染:只显示2楼POI
楼层与Tab索引的映射关系:
| Tab索引 | 楼层 | POI数量 | POI列表 |
|---|---|---|---|
| 0 | 1F | 5 | 护士站、输液室A、输液室B、急诊室、药房 |
| 1 | 2F | 2 | 护士站、住院部 |
| 2 | 3F | 1 | 住院部 |
6.3 快捷按钮导航
6.3.1 按钮设计
快捷导航按钮提供了一键搜索特定类型POI的功能:
Row() {
Button('护士站')
.onClick(() => {
this.targetPOI = NavService.findNearestPOI(this.userX, this.userY, this.currentFloor, 'nurse')
})
Button('输液室')
.onClick(() => {
this.targetPOI = NavService.findNearestPOI(this.userX, this.userY, this.currentFloor, 'infusion')
})
Button('急诊')
.onClick(() => {
this.targetPOI = NavService.findNearestPOI(this.userX, this.userY, this.currentFloor, 'emergency')
})
}

6.3.2 按钮交互流程
用户点击"护士站"按钮
↓
NavService.findNearestPOI(200, 150, 1, 'nurse')
↓
遍历8个POI,过滤floor=1且type='nurse'
↓
找到: poi_nurse_1f (200, 150)
↓
targetPOI = { id: 'poi_nurse_1f', name: '护士站', ... }
↓
HospitalMapView绘制:
- 蓝色虚线从用户位置到护士站
- 目的地信息卡片更新
6.3.3 快捷按钮与输液场景的关联
在IVGuard的输液监护场景中,三个快捷按钮分别对应不同的用户需求:
| 按钮 | 典型场景 | 用户画像 | 使用频率 |
|---|---|---|---|
| 护士站 | 输液过程中需要护士帮助 | 输液患者 | 高 |
| 输液室 | 需要前往输液室接受治疗 | 门诊患者 | 中 |
| 急诊 | 紧急情况需要急诊救治 | 急诊患者/家属 | 低 |
"护士站"按钮是最高频的使用场景。输液患者可能遇到以下情况需要寻找护士站:
- 输液瓶即将空瓶,需要护士更换
- 输液速度异常,需要护士调整
- 输液部位不适,需要护士检查
- 需要如厕,需要护士协助移动输液架
6.4 目的地信息卡片
当用户选择了目标POI后,信息卡片展示目的地的详细信息:
if (this.targetPOI) {
Column() {
Row() {
Text(this.targetPOI.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(`${this.targetPOI.floor}楼`)
.fontSize(14)
.fontColor('#666666')
}
Row() {
Text(`步行 ${this.walkTime} 分钟`)
.fontSize(14)
.fontColor('#4CAF50')
}
}
.padding(12)
.borderRadius(8)
.backgroundColor('#FFFFFF')
.shadow({ radius: 4, color: '#00000020', offsetY: 2 })
}
信息卡片的内容结构:
┌─────────────────────────────────┐
│ 📍 护士站 1楼 │
│ 🚶 步行 3 分钟 │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ 当前位置: 1楼 (200, 150) │
│ 目标位置: 1楼 (200, 150) │
│ 距离: 0px (同位置) │
└─────────────────────────────────┘
6.5 步行时间显示
步行时间的计算和显示流程:
@State walkTime: number = 0
// 当targetPOI变化时重新计算
private updateWalkTime() {
if (this.targetPOI) {
const userPOI: HospitalPOI = {
id: 'user', name: '', type: '', floor: this.currentFloor,
x: this.userX, y: this.userY, color: ''
}
this.walkTime = NavService.estimateWalkTime(userPOI, this.targetPOI)
}
}
步行时间显示的交互细节:
- 格式:整数分钟,如"步行 3 分钟"
- 最小值:至少显示1分钟
- 颜色:绿色(#4CAF50),与路径颜色一致
- 更新时机:每次选择新目标POI时更新
- 边界情况:当用户已在目标POI位置时,仍显示1分钟
6.6 "找护士站"按钮从MonitorPage跳转
IVGuard的输液监护页面(MonitorPage)提供了一个"找护士站"快捷入口,这是室内导航系统与核心业务功能的集成点。
6.6.1 跳转流程
MonitorPage(输液监护页面)
│
│ 用户点击"找护士站"按钮
↓
router.pushUrl({ url: 'pages/HospitalNavPage' })
│
│ 携带参数: targetNurse=true
↓
HospitalNavPage.aboutToAppear()
│
│ 检测参数: targetNurse=true
↓
自动搜索最近护士站:
NavService.findNearestPOI(200, 150, 1, 'nurse')
│
│ 找到: poi_nurse_1f
↓
自动显示导航路径和信息卡片
6.6.2 参数传递实现
// MonitorPage中的跳转按钮
Button('找护士站')
.onClick(() => {
router.pushUrl({
url: 'pages/HospitalNavPage',
params: { targetNurse: true }
})
})
// HospitalNavPage接收参数
aboutToAppear() {
const params = router.getParams() as Record<string, Object>
if (params && params['targetNurse']) {
this.targetPOI = NavService.findNearestPOI(this.userX, this.userY, this.currentFloor, 'nurse')
}
}
6.6.3 跳转场景的用户体验
这个跳转设计体现了IVGuard"监护+导航"一体化的产品理念:
- 输液异常 → 快速求助:当输液监护检测到异常(如流速过快、即将空瓶),用户可以一键跳转到导航页面,找到最近的护士站
- 减少认知负荷:不需要先记住自己在哪里、护士站在哪里,系统自动完成搜索和路径规划
- 紧急场景优化:跳转后自动显示导航路径,无需额外操作
6.7 交互状态机
HospitalNavPage的交互状态可以用以下状态机描述:
┌──────────┐ 选择楼层 ┌──────────┐
│ 初始态 │ ─────────→ │ 地图浏览 │
│ 无目标 │ │ 态 │
└──────────┘ └────┬─────┘
│
点击快捷按钮/跳转
│
↓
┌──────────────┐
│ 导航中 │
│ 有目标POI │
│ 显示路径 │
│ 显示时间 │
└──────┬───────┘
│
切换楼层/取消导航
│
↓
┌──────────────┐
│ 地图浏览态 │
│ 或新导航 │
└──────────────┘
状态转换规则:
| 当前状态 | 触发事件 | 目标状态 | 副作用 |
|---|---|---|---|
| 初始态 | 页面加载 | 地图浏览态 | 渲染1楼地图 |
| 地图浏览态 | 点击楼层Tab | 地图浏览态 | 切换楼层渲染 |
| 地图浏览态 | 点击快捷按钮 | 导航中 | 设置targetPOI,绘制路径 |
| 地图浏览态 | 从MonitorPage跳转 | 导航中 | 自动搜索护士站 |
| 导航中 | 切换楼层 | 地图浏览态 | 清除targetPOI |
| 导航中 | 点击其他快捷按钮 | 导航中 | 更换targetPOI |
| 导航中 | 返回按钮 | 退出页面 | — |
7. 室内定位实现方案
当前MVP版本使用手动楼层选择和固定用户位置。本章详细规划后续版本的室内定位实现方案,包括WiFi指纹、蓝牙信标和融合定位三种技术路线。
7.1 WiFi指纹采集与匹配
7.1.1 离线阶段:指纹采集
指纹采集是WiFi定位的基础工作,需要在目标建筑的每个参考点进行信号扫描。
采集流程:
步骤1:规划采集路线
┌─────────────────────────────┐
│ 1F采集路线: │
│ ┌──┬──┬──┬──┬──┬──┬──┬──┐ │
│ │01│02│03│04│05│06│07│08│ │
│ ├──┼──┼──┼──┼──┼──┼──┼──┤ │
│ │09│10│11│12│13│14│15│16│ │
│ ├──┼──┼──┼──┼──┼──┼──┼──┤ │
│ │17│18│19│20│21│22│23│24│ │
│ └──┴──┴──┴──┴──┴──┴──┴──┘ │
│ 每个网格中心 = 1个参考点 │
│ 网格间距: 2m │
└─────────────────────────────┘
步骤2:在每个参考点扫描
- 停留10-20秒
- 扫描3-5次WiFi信号
- 记录所有可见AP的BSSID和RSSI
- 取平均值作为该参考点的指纹
步骤3:构建指纹数据库
fingerprint_db = [
{ rp_id: 1, x: 1, y: 1, floor: 1,
signals: { "AP01": -45, "AP02": -62, "AP03": -58 } },
{ rp_id: 2, x: 3, y: 1, floor: 1,
signals: { "AP01": -52, "AP02": -55, "AP03": -61 } },
...
]
采集工具设计:
interface FingerprintSample {
rpId: number
x: number
y: number
floor: number
timestamp: number
apReadings: Array<{
bssid: string
ssid: string
rssi: number
frequency: number
}>
}
class FingerprintCollector {
private samples: FingerprintSample[] = []
async collectAtPoint(rpId: number, x: number, y: number, floor: number): Promise<void> {
const scanResults = wifiManager.getScanInfoList()
const sample: FingerprintSample = {
rpId,
x,
y,
floor,
timestamp: Date.now(),
apReadings: scanResults.map(ap => ({
bssid: ap.bssid,
ssid: ap.ssid,
rssi: ap.rssi,
frequency: ap.frequency
}))
}
this.samples.push(sample)
}
async collectMultiple(rpId: number, x: number, y: number, floor: number, count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await this.collectAtPoint(rpId, x, y, floor)
await this.delay(3000)
}
}
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}
指纹库数据量估算:
对于IVGuard所在的三层医院建筑:
| 参数 | 值 | 说明 |
|---|---|---|
| 楼层数 | 3 | 1F/2F/3F |
| 每层面积 | 40m × 40m = 1600m² | 假设 |
| 参考点间距 | 2m | 典型值 |
| 每层参考点数 | (40/2) × (40/2) = 400 | 20×20网格 |
| 总参考点数 | 400 × 3 = 1200 | 三层 |
| 每点AP数 | 5-15 | 取决于AP部署密度 |
| 每点采集时间 | 10s × 3次 = 30s | 3次扫描取平均 |
| 总采集时间 | 1200 × 30s = 10小时 | 纯采集时间 |
| 含走动时间 | 约15-20小时 | 实际工作量 |
7.1.2 在线阶段:实时定位
class WiFiPositioning {
private fingerprintDB: FingerprintRecord[] = []
async locate(): Promise<Position | undefined> {
const scanResults = wifiManager.getScanInfoList()
if (scanResults.length === 0) return undefined
const observedSignals: Record<string, number> = {}
for (const ap of scanResults) {
observedSignals[ap.bssid] = ap.rssi
}
return this.knnLocate(observedSignals, 5)
}
private knnLocate(observed: Record<string, number>, k: number): Position {
const distances: Array<{ rp: FingerprintRecord; dist: number }> = []
for (const rp of this.fingerprintDB) {
const dist = this.euclideanDistance(observed, rp.signals)
distances.push({ rp, dist })
}
distances.sort((a, b) => a.dist - b.dist)
const topK = distances.slice(0, k)
let totalWeight = 0
let weightedX = 0
let weightedY = 0
for (const item of topK) {
const weight = 1 / (item.dist + 0.001)
weightedX += weight * item.rp.x
weightedY += weight * item.rp.y
totalWeight += weight
}
return {
x: weightedX / totalWeight,
y: weightedY / totalWeight,
floor: topK[0].rp.floor
}
}
private euclideanDistance(
observed: Record<string, number>,
fingerprint: Record<string, number>
): number {
let sumSq = 0
const allBssids = new Set([
...Object.keys(observed),
...Object.keys(fingerprint)
])
for (const bssid of allBssids) {
const obs = observed[bssid] ?? -100
const fp = fingerprint[bssid] ?? -100
sumSq += Math.pow(obs - fp, 2)
}
return Math.sqrt(sumSq)
}
}
7.1.3 KNN算法详解
KNN(K-Nearest Neighbor)算法的核心步骤:
- 距离计算:计算实时信号与每个参考点指纹的欧氏距离
- 排序:按距离从小到大排序
- 选择:取距离最小的K个参考点
- 加权平均:以距离的倒数作为权重,对K个参考点的坐标进行加权平均
K值选择的影响:
| K值 | 精度 | 稳定性 | 计算量 | 说明 |
|---|---|---|---|---|
| 1 | 较低 | 差 | 最小 | NN算法,易受噪声影响 |
| 3 | 中等 | 中等 | 较小 | 常用选择 |
| 5 | 较高 | 好 | 中等 | 推荐值 |
| 7 | 高 | 很好 | 较大 | 边际收益递减 |
| 10 | 高 | 很好 | 大 | 可能过度平滑 |
7.1.4 精度评估
WiFi指纹定位的精度受多种因素影响:
| 因素 | 影响 | 优化方法 |
|---|---|---|
| 参考点密度 | 间距越小精度越高 | 关键区域加密参考点 |
| AP数量 | AP越多定位越准 | 部署足够AP(每点≥3个可见AP) |
| 信号稳定性 | 人体遮挡造成3-5dB波动 | 多次扫描取平均 |
| 环境变化 | 家具移动、人员密度变化 | 定期更新指纹库 |
| 设备差异 | 不同手机WiFi天线差异 | 设备指纹校准 |
典型精度指标:
- 中位数误差:3-5m
- 90%误差:8-12m
- 95%误差:10-15m
7.2 蓝牙信标部署建议
7.2.1 信标部署密度计算
根据iBeacon的信号覆盖范围和定位精度要求,计算所需的信标数量:
定位精度目标: 2m
信标覆盖半径: ~10m(Far区域边界)
三角定位需求: 至少3个信标信号重叠
每层所需信标数 ≈ 楼层面积 / (π × 覆盖半径² / 3)
≈ 1600m² / (π × 100 / 3)
≈ 1600 / 105
≈ 15个/层
三层总计: 15 × 3 = 45个信标
实际部署时,还需要考虑:
- 走廊区域:沿走廊每隔8-10m部署一个
- 大厅区域:4-6个信标均匀分布
- 房间内:重要房间(如急诊室)内部署1-2个
- 电梯厅:每层电梯厅部署1个,用于楼层判别
7.2.2 信标编码方案
interface BeaconConfig {
uuid: string // 医院统一UUID
major: number // 楼层编号
minor: number // 区域编号
txPower: number // 发射功率校准值
interval: number // 广播间隔(ms)
position: Position // 信标物理位置
}
const hospitalBeacons: BeaconConfig[] = [
// 1楼
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 1, minor: 1, txPower: -59, interval: 100,
position: { x: 50, y: 50, floor: 1 } }, // 急诊区
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 1, minor: 2, txPower: -59, interval: 100,
position: { x: 200, y: 150, floor: 1 } }, // 护士站
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 1, minor: 3, txPower: -59, interval: 100,
position: { x: 100, y: 300, floor: 1 } }, // 输液室A
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 1, minor: 4, txPower: -59, interval: 100,
position: { x: 300, y: 300, floor: 1 } }, // 输液室B
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 1, minor: 5, txPower: -59, interval: 100,
position: { x: 350, y: 100, floor: 1 } }, // 药房
// 2楼
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 2, minor: 1, txPower: -59, interval: 100,
position: { x: 200, y: 150, floor: 2 } }, // 护士站2F
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 2, minor: 2, txPower: -59, interval: 100,
position: { x: 100, y: 200, floor: 2 } }, // 住院部2F
// 3楼
{ uuid: 'FDA50693-A4E2-4FB1-AFCF-C6EB07647825',
major: 3, minor: 1, txPower: -59, interval: 100,
position: { x: 100, y: 200, floor: 3 } } // 住院部3F
]
7.2.3 RSSI测距+三角定位
class BLEPositioning {
private beaconPositions: Map<string, Position> = new Map()
private pathLossExponent: number = 2.5
async locate(): Promise<Position | undefined> {
const beacons = await this.scanBeacons()
if (beacons.length < 3) return undefined
const distances: Array<{ beacon: BeaconInfo; distance: number }> = []
for (const beacon of beacons) {
const distance = this.estimateDistance(beacon.rssi, beacon.txPower)
const position = this.beaconPositions.get(beacon.id)
if (position) {
distances.push({ beacon, distance })
}
}
if (distances.length < 3) return undefined
return this.trilaterate(distances)
}
private estimateDistance(rssi: number, txPower: number): number {
if (rssi >= 0) return 0.1
const ratio = (txPower - rssi) / (10 * this.pathLossExponent)
return Math.pow(10, ratio)
}
private trilaterate(
measurements: Array<{ beacon: BeaconInfo; distance: number }>
): Position {
const sorted = measurements.sort((a, b) => a.distance - b.distance)
const top3 = sorted.slice(0, 3)
const [m1, m2, m3] = top3
const p1 = this.beaconPositions.get(m1.beacon.id)!
const p2 = this.beaconPositions.get(m2.beacon.id)!
const p3 = this.beaconPositions.get(m3.beacon.id)!
const A = 2 * (p2.x - p1.x)
const B = 2 * (p2.y - p1.y)
const C = m1.distance * m1.distance - m2.distance * m2.distance
- p1.x * p1.x + p2.x * p2.x
- p1.y * p1.y + p2.y * p2.y
const D = 2 * (p3.x - p2.x)
const E = 2 * (p3.y - p2.y)
const F = m2.distance * m2.distance - m3.distance * m3.distance
- p2.x * p2.x + p3.x * p3.x
- p2.y * p2.y + p3.y * p3.y
const x = (C * E - F * B) / (E * A - D * B)
const y = (C * D - A * F) / (B * D - A * E)
return { x, y, floor: p1.floor }
}
}
7.2.4 BLE定位精度
蓝牙信标定位的精度受以下因素影响:
| 因素 | 影响 | 典型值 |
|---|---|---|
| 信标密度 | 密度越高精度越高 | 每10-15m一个 |
| RSSI波动 | 2-5dB的随机波动 | 导致1-3m误差 |
| 人体遮挡 | 人体吸收2.4GHz信号 | 3-8dB衰减 |
| 多径效应 | 金属/玻璃反射 | 距离估算偏差 |
| 环境温湿度 | 影响信号传播 | 轻微影响 |
典型精度指标:
- 理想环境:1-2m
- 一般室内:2-5m
- 人员密集:3-8m
7.3 HarmonyOS融合定位
单一技术难以同时满足精度、稳定性和成本的要求。融合定位通过结合多种定位源,利用各技术的互补优势,实现更稳定、更精确的定位效果。
7.3.1 WiFi+蓝牙融合
class FusedPositioning {
private wifiPositioning: WiFiPositioning
private blePositioning: BLEPositioning
private lastPosition: Position | undefined = undefined
async locate(): Promise<Position | undefined> {
const wifiPos = await this.wifiPositioning.locate()
const blePos = await this.blePositioning.locate()
if (!wifiPos && !blePos) return this.lastPosition
if (!wifiPos) { this.lastPosition = blePos; return blePos }
if (!blePos) { this.lastPosition = wifiPos; return wifiPos }
const fusedPos = this.fuse(wifiPos, blePos)
this.lastPosition = fusedPos
return fusedPos
}
private fuse(wifiPos: Position, blePos: Position): Position {
const wifiWeight = 0.3
const bleWeight = 0.7
return {
x: wifiPos.x * wifiWeight + blePos.x * bleWeight,
y: wifiPos.y * wifiWeight + blePos.y * bleWeight,
floor: blePos.floor
}
}
}
融合权重分配逻辑:
- BLE精度更高(1-3m vs 3-5m),权重更大(0.7 vs 0.3)
- 楼层信息以BLE为准(Major编码直接确定楼层)
- 当某一信号源不可用时,退化为单源定位
7.3.2 PDR(行人航位推算)
PDR(Pedestrian Dead Reckoning)利用手机内置的惯性传感器(加速度计、陀螺仪)推算行人的行走轨迹,作为定位的辅助手段。
核心算法:
PDR推算步骤:
1. 步行检测:加速度计信号峰值检测
2. 步长估计:根据加速度幅值估计步长(0.5-0.8m)
3. 方向估计:陀螺仪积分得到行走方向
4. 位置更新:
x_new = x_old + step_length × cos(heading)
y_new = y_old + step_length × sin(heading)
class PDRTracker {
private x: number = 0
private y: number = 0
private heading: number = 0
private stepLength: number = 0.65
private lastAccel: number[] = [0, 0, 0]
private stepThreshold: number = 1.2
private isWalking: boolean = false
update(accel: number[], gyro: number[]): void {
const accelMag = Math.sqrt(
accel[0] * accel[0] + accel[1] * accel[1] + accel[2] * accel[2]
)
if (accelMag > this.stepThreshold + 9.8 && !this.isWalking) {
this.isWalking = true
this.heading += gyro[2] * 0.02
this.x += this.stepLength * Math.cos(this.heading)
this.y += this.stepLength * Math.sin(this.heading)
} else if (accelMag < this.stepThreshold + 9.8 - 0.5) {
this.isWalking = false
}
}
getPosition(): Position {
return { x: this.x, y: this.y, floor: 0 }
}
reset(x: number, y: number, floor: number): void {
this.x = x
this.y = y
this.heading = 0
}
}
PDR的优缺点:
| 优点 | 缺点 |
|---|---|
| 不依赖外部信号 | 累积误差随时间增大 |
| 短期精度高(<30s内<1m) | 需要初始位置校准 |
| 更新频率高(50-100Hz) | 无法判断楼层 |
| 不受环境遮挡影响 | 方向估计受手机姿态影响 |
7.3.3 地图匹配
地图匹配(Map Matching)利用地图信息约束定位结果,将推算的轨迹"吸附"到合理的路径上,消除穿越墙壁等不合理结果。
class MapMatcher {
private corridors: CorridorSegment[] = []
private maxSnapDistance: number = 5
match(position: Position): Position {
let nearestPoint: Position = position
let minDist = Number.MAX_VALUE
for (const corridor of this.corridors) {
const projected = this.projectToSegment(position, corridor)
const dist = this.distance(position, projected)
if (dist < minDist && dist < this.maxSnapDistance) {
minDist = dist
nearestPoint = projected
}
}
return nearestPoint
}
private projectToSegment(point: Position, segment: CorridorSegment): Position {
const dx = segment.endX - segment.startX
const dy = segment.endY - segment.startY
const lengthSq = dx * dx + dy * dy
if (lengthSq === 0) {
return { x: segment.startX, y: segment.startY, floor: point.floor }
}
let t = ((point.x - segment.startX) * dx + (point.y - segment.startY) * dy) / lengthSq
t = Math.max(0, Math.min(1, t))
return {
x: segment.startX + t * dx,
y: segment.startY + t * dy,
floor: point.floor
}
}
private distance(a: Position, b: Position): number {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2))
}
}
7.3.4 完整融合定位架构
┌─────────────────────────────────────────────────┐
│ 融合定位引擎 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ WiFi指纹 │ │ BLE信标 │ │ PDR推算 │ │
│ │ 3-5m │ │ 1-3m │ │ 累积误差 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────┬───────┘ │ │
│ │ │ │
│ ┌─────↓─────┐ │ │
│ │ 加权融合 │ │ │
│ │ 0.3+0.7 │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ┌─────↓─────┐ │ │
│ │ 位置校准 │←──────────────┘ │
│ │ 重置PDR │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────↓─────┐ │
│ │ 地图匹配 │ │
│ │ 路径约束 │ │
│ └─────┬─────┘ │
│ │ │
│ ┌─────↓─────┐ │
│ │ 最终位置 │ │
│ │ 1-2m精度 │ │
│ └───────────┘ │
└─────────────────────────────────────────────────┘
融合定位的工作流程:
- WiFi扫描:每10-15秒获取一次WiFi定位结果
- BLE扫描:每1-3秒获取一次BLE定位结果
- 加权融合:WiFi(0.3) + BLE(0.7) = 融合位置
- PDR补充:在WiFi/BLE扫描间隔期间,使用PDR推算位置
- 位置校准:每次获得新的融合位置时,重置PDR的起点
- 地图匹配:将最终位置吸附到最近的走廊路径上
这种融合方案的预期精度:
| 定位源 | 精度 | 更新频率 |
|---|---|---|
| WiFi | 3-5m | 10-15s |
| BLE | 1-3m | 1-3s |
| PDR | 0.5-1m(30s内) | 50Hz |
| 融合+匹配 | 1-2m | 实时 |
8. 未来:实时路径引导
实时路径引导是室内导航系统的终极目标,将静态的路径规划转变为动态的、伴随式的导航体验。本章规划IVGuard未来版本的实时路径引导功能。
8.1 转弯提示
8.1.1 路径分段与方向计算
将路径分解为多个路段,在每个转弯点生成方向提示:
interface PathSegment {
from: Position
to: Position
direction: Direction
distance: number
turnAngle: number
instruction: string
}
enum Direction {
STRAIGHT = '直行',
LEFT = '左转',
RIGHT = '右转',
BACK = '掉头',
SLIGHT_LEFT = '稍向左',
SLIGHT_RIGHT = '稍向右',
ELEVATOR = '乘电梯',
STAIRS = '走楼梯'
}
class PathInstructionGenerator {
generateInstructions(path: Position[]): PathSegment[] {
const segments: PathSegment[] = []
for (let i = 0; i < path.length - 1; i++) {
const from = path[i]
const to = path[i + 1]
const dx = to.x - from.x
const dy = to.y - from.y
const distance = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx)
let direction = Direction.STRAIGHT
let turnAngle = 0
let instruction = ''
if (i > 0) {
const prevDx = path[i].x - path[i - 1].x
const prevDy = path[i].y - path[i - 1].y
const prevAngle = Math.atan2(prevDy, prevDx)
turnAngle = angle - prevAngle
direction = this.angleToDirection(turnAngle)
}
instruction = this.buildInstruction(direction, distance, to)
segments.push({ from, to, direction, distance, turnAngle, instruction })
}
return segments
}
private angleToDirection(angle: number): Direction {
const normalized = ((angle + Math.PI) % (2 * Math.PI)) - Math.PI
const degrees = normalized * 180 / Math.PI
if (degrees > 150 || degrees < -150) return Direction.BACK
if (degrees > 100) return Direction.LEFT
if (degrees > 30) return Direction.SLIGHT_LEFT
if (degrees > -30) return Direction.STRAIGHT
if (degrees > -100) return Direction.SLIGHT_RIGHT
return Direction.RIGHT
}
private buildInstruction(direction: Direction, distance: number, target: Position): string {
const distMeters = Math.round(distance * 0.1)
switch (direction) {
case Direction.STRAIGHT:
return `直行${distMeters}米`
case Direction.LEFT:
return `前方左转,再行${distMeters}米`
case Direction.RIGHT:
return `前方右转,再行${distMeters}米`
case Direction.SLIGHT_LEFT:
return `稍向左前方行${distMeters}米`
case Direction.SLIGHT_RIGHT:
return `稍向右前方行${distMeters}米`
case Direction.ELEVATOR:
return `乘电梯至${target.floor}楼`
case Direction.STAIRS:
return `走楼梯至${target.floor}楼`
default:
return `行${distMeters}米`
}
}
}
8.1.2 提示触发时机
用户沿路径行走:
┌──── 目标
│
┌──── 转弯点2 │
│ (左转) │
┌── 转弯点1 │ │
│ (右转) │ │
│ │ │
起点──────────────┴────────────────────┘
触发逻辑:
- 距离转弯点5m时:提前提示"前方5米右转"
- 距离转弯点2m时:确认提示"现在右转"
- 通过转弯点后:更新下一路段"直行20米"
- 距离终点10m时:提示"即将到达"
- 到达终点:提示"您已到达护士站"
8.2 语音导航:TTS集成
8.2.1 HarmonyOS TTS能力
HarmonyOS提供了文本转语音(Text-to-Speech, TTS)服务,可以将导航指令转换为语音播报:
import { textToSpeech } from '@kit.AI.Core'
class VoiceNavigator {
private ttsEngine: textToSpeech.TextToSpeechEngine | undefined = undefined
async init(): Promise<void> {
const initParams: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
online: 0
}
this.ttsEngine = textToSpeech.createEngine(initParams)
}
async speak(text: string): Promise<void> {
if (!this.ttsEngine) await this.init()
const speakParams: textToSpeech.SpeakParams = {
requestId: Date.now().toString(),
extraParams: { speed: 1.0, pitch: 1.0, volume: 2 }
}
this.ttsEngine?.speak(text, speakParams)
}
async speakInstruction(instruction: PathSegment): Promise<void> {
await this.speak(instruction.instruction)
}
stop(): void {
this.ttsEngine?.stop()
}
shutdown(): void {
this.ttsEngine?.shutdown()
}
}
8.2.2 语音导航交互设计
语音导航需要考虑以下用户体验问题:
| 问题 | 解决方案 |
|---|---|
| 语音打断 | 当前指令播报期间,新指令排队等待 |
| 音量控制 | 导航语音独立音量,不影响媒体播放 |
| 蓝牙耳机 | 支持通过蓝牙耳机播报,保护隐私 |
| 静音模式 | 震动+视觉提示替代语音 |
| 语言选择 | 支持中英文切换 |
| 语速调节 | 老年用户可选择慢速播报 |
8.3 AR叠加导航
8.3.1 AR导航概念
AR(Augmented Reality,增强现实)导航将虚拟的导航箭头叠加到手机相机画面上,用户只需跟随箭头方向行走即可到达目的地。
AR导航画面:
┌─────────────────────────────────────┐
│ │
│ [相机实时画面] │
│ │
│ ╱ │
│ ╱ → 蓝色导航箭头 │
│ ╱ │
│ ╱ │
│ ╱ │
│ ╱ "前方20米左转" │
│ ╱ │
│ ╱ │
│ ╱─────────────── │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 护士站 │ │ 3分钟 │ │
│ │ 1楼 │ │ 120米 │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
8.3.2 AR导航技术要求
| 技术组件 | 要求 | HarmonyOS能力 |
|---|---|---|
| 相机画面 | 后置摄像头实时预览 | Camera Kit |
| 姿态估计 | 设备方向和位置 | 陀螺仪+磁力计 |
| 3D渲染 | 导航箭头叠加 | Component叠加层 |
| 平面检测 | 地面和墙壁识别 | AR Engine |
| 空间锚点 | 箭头固定在空间中 | AR Engine |
8.3.3 简化AR导航实现
完整的AR导航需要AR Engine支持,以下是一个简化的实现方案——使用相机画面+方向指示器:
@Component
struct ARNavView {
@Prop heading: number = 0
@Prop targetBearing: number = 0
@Prop distance: number = 0
build() {
Stack() {
CameraPreview()
Column() {
this.DirectionIndicator()
this.NavInfo()
}
}
}
@Builder DirectionIndicator() {
Canvas(this.context)
.width(200)
.height(200)
.onReady(() => {
const ctx = this.context
const relBearing = this.targetBearing - this.heading
ctx.translate(100, 100)
ctx.rotate(relBearing * Math.PI / 180)
ctx.fillStyle = '#2196F3'
ctx.beginPath()
ctx.moveTo(0, -60)
ctx.lineTo(-20, 20)
ctx.lineTo(0, 10)
ctx.lineTo(20, 20)
ctx.closePath()
ctx.fill()
})
}
@Builder NavInfo() {
Column() {
Text(`${Math.round(this.distance * 0.1)}米`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
}
.padding(16)
.borderRadius(8)
.backgroundColor('#80000000')
}
}
8.4 电梯/楼梯识别
8.4.1 跨楼层导航
跨楼层导航需要识别电梯和楼梯位置,并在路径规划中正确处理楼层切换:
interface FloorTransition {
id: string
type: 'elevator' | 'stairs'
position: Position
connectedFloors: number[]
estimatedTime: number
}
const floorTransitions: FloorTransition[] = [
{
id: 'elevator_1',
type: 'elevator',
position: { x: 380, y: 380, floor: 1 },
connectedFloors: [1, 2, 3],
estimatedTime: 60
},
{
id: 'stairs_1',
type: 'stairs',
position: { x: 30, y: 380, floor: 1 },
connectedFloors: [1, 2],
estimatedTime: 120
}
]
8.4.2 跨楼层路径规划
class CrossFloorPathPlanner {
planPath(from: Position, to: Position): PathResult {
if (from.floor === to.floor) {
return this.sameFloorPath(from, to)
}
const transitions = this.findAvailableTransitions(from.floor, to.floor)
let bestPath: PathResult | undefined = undefined
for (const transition of transitions) {
const pathToTransition = this.sameFloorPath(from, transition.position)
const transitionSegment = this.createTransitionSegment(transition, to.floor)
const pathFromTransition = this.sameFloorPath(
{ ...transition.position, floor: to.floor }, to
)
const totalTime = pathToTransition.time + transition.estimatedTime + pathFromTransition.time
if (!bestPath || totalTime < bestPath.time) {
bestPath = {
segments: [
...pathToTransition.segments,
transitionSegment,
...pathFromTransition.segments
],
time: totalTime,
distance: pathToTransition.distance + pathFromTransition.distance
}
}
}
return bestPath ?? this.fallbackPath(from, to)
}
private findAvailableTransitions(fromFloor: number, toFloor: number): FloorTransition[] {
return floorTransitions.filter(t =>
t.connectedFloors.includes(fromFloor) && t.connectedFloors.includes(toFloor)
)
}
private createTransitionSegment(transition: FloorTransition, targetFloor: number): PathSegment {
return {
from: transition.position,
to: { ...transition.position, floor: targetFloor },
direction: transition.type === 'elevator' ? Direction.ELEVATOR : Direction.STAIRS,
distance: 0,
turnAngle: 0,
instruction: transition.type === 'elevator'
? `乘电梯至${targetFloor}楼`
: `走楼梯至${targetFloor}楼`
}
}
}
8.4.3 楼层检测算法
利用气压计传感器检测楼层变化:
class FloorDetector {
private basePressure: number = 1013.25
private pressurePerFloor: number = 0.36
detectFloor(currentPressure: number, baseFloor: number): number {
const pressureDiff = currentPressure - this.basePressure
const floorDiff = Math.round(pressureDiff / this.pressurePerFloor)
return baseFloor + floorDiff
}
}
气压计原理:海拔每升高约10m,大气压降低约1.2hPa。医院每层约3-4m,对应约0.36-0.48hPa的气压差。结合BLE信标的Major值和气压计数据,可以实现可靠的楼层检测。
8.5 版本演进路线图
v1.0 MVP (当前版本)
├── Canvas静态平面图
├── 手动楼层切换
├── 直线两点路径
├── 快捷按钮搜索
└── 步行时间估算
v1.1 (BLE定位版)
├── + iBeacon信标部署
├── + BLE三角定位
├── + 用户位置自动更新
├── + Dijkstra最短路径
└── + 路径距离计算
v1.2 (融合定位版)
├── + WiFi指纹库采集
├── + WiFi+BLE融合定位
├── + PDR行人航位推算
├── + 地图匹配约束
└── + 跨楼层路径规划
v2.0 (实时引导版)
├── + 转弯提示生成
├── + TTS语音导航
├── + 气压计楼层检测
├── + 电梯/楼梯识别
└── + 实时位置跟踪
v3.0 (AR导航版)
├── + AR叠加导航箭头
├── + 相机画面方向指引
├── + AR Engine集成
├── + 无障碍语音增强
└── + 多语言导航支持
8.6 技术债务与优化方向
| 当前问题 | 优先级 | 计划版本 | 解决方案 |
|---|---|---|---|
| 房间填充色与POI色系不一致 | P2 | v1.1 | 统一色系映射 |
| 步行速度假设偏低 | P2 | v1.1 | 校准像素-实际距离比例 |
| 无障碍访问不足 | P1 | v1.1 | 添加语义化标注和VoiceOver支持 |
| POI数据硬编码 | P2 | v1.2 | 从JSON配置文件加载 |
| 无路径动画 | P3 | v1.1 | 添加路径绘制动画 |
| 地图不可缩放 | P2 | v1.2 | 添加手势缩放和平移 |
| 无离线地图 | P3 | v2.0 | 预缓存地图数据 |
| POI无详情页 | P2 | v1.1 | 添加POI点击展开详情 |
附录
A. 术语表
| 术语 | 英文 | 说明 |
|---|---|---|
| POI | Point of Interest | 兴趣点,地图上的标记位置 |
| RSSI | Received Signal Strength Indicator | 接收信号强度指示 |
| BSSID | Basic Service Set Identifier | AP的MAC地址标识 |
| KNN | K-Nearest Neighbor | K最邻近算法 |
| BLE | Bluetooth Low Energy | 低功耗蓝牙 |
| UWB | Ultra-Wideband | 超宽带 |
| PDR | Pedestrian Dead Reckoning | 行人航位推算 |
| TWR | Two-Way Ranging | 双向测距 |
| TDoA | Time Difference of Arrival | 到达时间差 |
| ToF | Time of Flight | 飞行时间 |
| TTS | Text-to-Speech | 文本转语音 |
| AR | Augmented Reality | 增强现实 |
| DPR | Device Pixel Ratio | 设备像素比 |
B. 颜色参考表
| 用途 | 颜色名 | 色值 | RGB |
|---|---|---|---|
| 护士站 | Green 500 | #4CAF50 | (76, 175, 80) |
| 输液室 | Blue 500 | #2196F3 | (33, 150, 243) |
| 急诊室 | Red 500 | #F44336 | (244, 67, 54) |
| 药房 | Orange 500 | #FF9800 | (255, 152, 0) |
| 住院部 | Purple 500 | #9C27B0 | (156, 39, 176) |
| 用户位置 | Blue 500 | #2196F3 | (33, 150, 243) |
| 用户标注 | Blue 800 | #1565C0 | (21, 101, 192) |
| 导航路径 | Green 500 | #4CAF50 | (76, 175, 80) |
| 地图背景 | Grey 100 | #F5F5F5 | (245, 245, 245) |
| 墙壁描边 | Grey 900 | #333333 | (51, 51, 51) |
| 文字标注 | Grey 900 | #333333 | (51, 51, 51) |
| 输液区填充 | Green 50 | #E8F5E9 | (232, 245, 233) |
| 护士站填充 | Blue 50 | #E3F2FD | (227, 242, 253) |
| 急诊区填充 | Red 50 | #FFEBEE | (255, 235, 238) |
C. 代码统计
| 文件 | 行数 | 核心功能 |
|---|---|---|
| NavService.ets | 40 | POI搜索+路径计算+时间估算 |
| HospitalData.ets | 38 | POI数据+路径数据+地图参数 |
| HospitalMapView.ets | 97 | Canvas 2D地图渲染 |
| HospitalNavPage.ets | ~60 | 页面交互+状态管理 |
| 总计 | ~235 | 室内导航MVP |
更多推荐




所有评论(0)