基于鸿蒙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 m95%+
城市峡谷10-50 m80%+
靠近窗户20-100 m40-60%
室内深处不可用<5%

结论:GPS在医院室内导航场景下完全不可用,必须采用专门的室内定位技术。

1.3 WiFi指纹定位

WiFi指纹定位(WiFi Fingerprinting)是目前最成熟的室内定位技术之一,其核心思想是利用室内已部署的WiFi接入点(Access Point, AP)的信号强度特征作为位置指纹。

1.3.1 工作原理

WiFi指纹定位分为两个阶段:

离线采集阶段(Training Phase)

  1. 在目标建筑的每个参考点(Reference Point, RP)进行信号采集
  2. 参考点间距通常为1-3米
  3. 在每个参考点,扫描周围所有可见AP的BSSID和RSSI(Received Signal Strength Indicator)
  4. 对多次扫描结果取平均,构建该参考点的指纹向量
  5. 将所有参考点的指纹向量存入指纹数据库

指纹向量的数据结构:

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)

  1. 移动设备实时扫描周围AP的RSSI
  2. 将实时扫描结果与指纹数据库中的记录进行匹配
  3. 使用匹配算法计算最可能的位置
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
Near0.5-3m-40 ~ -60 dBm
Far3-10m-60 ~ -80 dBm
不可见> 10m< -80 dBm
1.4.3 三角定位

当设备同时接收到3个或更多信标的信号时,可以通过三角定位法计算设备位置:

  1. 测量设备到每个信标的距离 d₁, d₂, d₃
  2. 以每个信标位置为圆心,对应距离为半径画圆
  3. 三个圆的交点即为设备位置

数学模型:

(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 各方案对比

维度GPSWiFi指纹BLE信标UWB
精度10-50m(室外)3-5m1-3m0.1-0.3m
室内可用性不可用可用可用可用
硬件成本无(手机内置)低(利用现有AP)中(信标50-150元/个)高(锚点500-2000元/个)
部署难度N/A高(指纹采集工作量大)中(信标安装+配置)高(锚点安装+同步)
维护成本高(定期重采指纹)低(换电池)中(设备校准)
手机兼容性通用通用需BLE 4.0+需UWB芯片
响应延迟1-2s10-15s1-3s<1s
楼层识别不可靠可靠(指纹含楼层信息)可靠(Major编码)可靠(3D定位)
适用场景室外导航区域级定位房间级定位厘米级定位

IVGuard MVP选择:当前版本采用静态平面图+手动定位的方案,原因如下:

  1. MVP阶段聚焦核心输液监护功能,室内导航作为辅助体验
  2. 避免引入硬件部署依赖,降低MVP验证门槛
  3. 为后续版本预留定位技术升级接口
  4. 静态平面图已能解决大部分寻路需求(查看地图、了解布局、搜索POI)

后续版本路线图

  • v1.1: 集成BLE信标定位
  • v1.2: WiFi指纹+BLE融合定位
  • v2.0: UWB高精度定位+实时路径引导

2. HarmonyOS定位与地图能力

2.1 位置权限配置

HarmonyOS对位置信息的访问实行严格的权限管控。应用必须在module.json5中声明所需权限,并在运行时向用户申请授权。

2.1.1 权限声明

module.json5requestPermissions字段中声明位置相关权限:

{
  "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对象包含的关键字段:

字段类型说明
ssidstring网络名称
bssidstringAP的MAC地址
rssinumber信号强度(dBm)
bandnumber频段(2.4G/5G)
frequencynumber中心频率(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数量增长到数百甚至数千时,可以考虑以下优化方案:

空间索引优化

  1. 网格索引:将地图空间划分为等大小的网格,每个网格维护其中的POI列表。搜索时只查询用户所在网格及相邻网格的POI,将搜索范围从O(n)降低到O(1)(假设网格内POI数量有界)。

  2. KD-Tree:构建二维KD-Tree索引,最近邻查询的时间复杂度为O(log n)。适合POI数量大且查询频繁的场景。

  3. 按楼层分组:将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 ID
  • path[path.length-1]: 终点POI ID
  • path[1..n-2]: 中间途径点POI ID(可选)
  • 相邻元素之间表示一段可通行的路径
3.3.4 MVP限制与未来演进
版本算法路径质量计算复杂度
MVP直连+查表两点直线O(1)
v1.1Dijkstra最短路径O((V+E)logV)
v1.2A*启发式最短路径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像素/分钟

这个速度假设基于以下换算:

  1. 地图比例:当前地图400px × 400px对应约40m × 40m的实际空间
  2. 比例尺:1px ≈ 0.1m = 10cm
  3. 普通人步行速度:约80m/分钟(4.8km/h)
  4. 像素速度: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 > 0dist/80 > 0Math.ceil结果≥1)
  • 起点和终点相同时,dist = 0Math.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'
}

字段详解:

字段类型用途示例
idstring唯一标识,用于路径引用poi_nurse_1f
namestringUI显示名称护士站
typestring类型过滤和颜色映射nurse
floornumber楼层过滤1
xnumber地图水平坐标200
ynumber地图垂直坐标150
colorstringCanvas渲染颜色#4CAF50

ID命名规范:poi_{类型简称}_{楼层},例如:

  • poi_nurse_1f:1楼护士站
  • poi_infusion_1f:1楼输液室A
  • poi_infusion_b_1f:1楼输液室B
  • poi_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住院、长期

颜色选择遵循以下原则:

  1. 国际惯例:红色=紧急/危险,绿色=安全/医护,与医疗行业标准一致
  2. 可辨识度:5种颜色在色轮上均匀分布,色差明显,不会混淆
  3. 无障碍:所有颜色饱和度适中,在浅色背景上对比度足够
  4. 语义映射:颜色与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数组,表示从起点到终点依次经过的节点序列。

键的设计考量:

  1. 可读性poi_infusion_1f->poi_nurse_1f一目了然地表示路径方向
  2. 双向性:正向键和反向键分别存储,calculatePath方法会同时检查两个方向
  3. 扩展性:中间途径点直接在数组中添加,键格式不需要修改
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 }
参数说明
楼层数31F/2F/3F三层
地图宽度400pxCanvas绘制宽度
地图高度400pxCanvas绘制高度

地图尺寸选择400×400px的原因:

  1. 正方形布局:便于在不同屏幕尺寸上等比缩放
  2. 适中分辨率:在手机屏幕上足够清晰,同时不会造成Canvas绘制性能问题
  3. 坐标范围友好:0-400的范围便于手动设置POI坐标
  4. 设备适配:通过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作为数据层,向上提供只读的数据访问接口,不包含任何业务逻辑。这种分层设计的好处:

  1. 数据与逻辑解耦:修改POI数据不需要修改导航算法
  2. 数据源可替换:未来可以从本地JSON文件或远程API加载数据
  3. 便于测试:可以注入测试数据验证导航算法
  4. 职责单一:每个类的职责清晰,便于维护

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)
}

绘制层次:

  1. 外墙strokeRect(10, 10, 380, 380) — 描边2px的深灰色矩形,表示建筑外围
  2. 房间填充:每个房间使用淡色填充,颜色与该区域POI颜色同色系但更浅
  3. 语义关联:房间填充色与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作为路径颜色,与护士站的绿色一致。选择绿色的原因:

  1. 导航语义:绿色表示"通行"、“安全路线”
  2. 与墙壁区分:灰色墙壁 vs 绿色路径,对比明显
  3. 与背景区分:浅灰背景上的绿色线条清晰可见
  4. 与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)类型相关色
半径6px8px
文字“您在这里”POI名称
文字位置上方下方
文字颜色深蓝深灰
绘制顺序最上层中间层

绘制顺序确保用户位置蓝点始终可见,不被路径线或POI标记遮挡。

5.6 楼层切换渲染

@Prop currentFloor: number = 1

currentFloor作为组件的属性(@Prop),由父组件HospitalNavPage传入。当楼层切换时,currentFloor值变化触发组件重新渲染:

  1. drawPOIs()中的楼层过滤:if (poi.floor !== this.currentFloor) continue
  2. drawPath()中使用this.currentFloor构建临时POI对象
  3. drawUserPosition()使用this.userXthis.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列表
01F5护士站、输液室A、输液室B、急诊室、药房
12F2护士站、住院部
23F1住院部

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的输液监护场景中,三个快捷按钮分别对应不同的用户需求:

按钮典型场景用户画像使用频率
护士站输液过程中需要护士帮助输液患者
输液室需要前往输液室接受治疗门诊患者
急诊紧急情况需要急诊救治急诊患者/家属

"护士站"按钮是最高频的使用场景。输液患者可能遇到以下情况需要寻找护士站:

  1. 输液瓶即将空瓶,需要护士更换
  2. 输液速度异常,需要护士调整
  3. 输液部位不适,需要护士检查
  4. 需要如厕,需要护士协助移动输液架

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)
  }
}

步行时间显示的交互细节:

  1. 格式:整数分钟,如"步行 3 分钟"
  2. 最小值:至少显示1分钟
  3. 颜色:绿色(#4CAF50),与路径颜色一致
  4. 更新时机:每次选择新目标POI时更新
  5. 边界情况:当用户已在目标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"监护+导航"一体化的产品理念:

  1. 输液异常 → 快速求助:当输液监护检测到异常(如流速过快、即将空瓶),用户可以一键跳转到导航页面,找到最近的护士站
  2. 减少认知负荷:不需要先记住自己在哪里、护士站在哪里,系统自动完成搜索和路径规划
  3. 紧急场景优化:跳转后自动显示导航路径,无需额外操作

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所在的三层医院建筑:

参数说明
楼层数31F/2F/3F
每层面积40m × 40m = 1600m²假设
参考点间距2m典型值
每层参考点数(40/2) × (40/2) = 40020×20网格
总参考点数400 × 3 = 1200三层
每点AP数5-15取决于AP部署密度
每点采集时间10s × 3次 = 30s3次扫描取平均
总采集时间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)算法的核心步骤:

  1. 距离计算:计算实时信号与每个参考点指纹的欧氏距离
  2. 排序:按距离从小到大排序
  3. 选择:取距离最小的K个参考点
  4. 加权平均:以距离的倒数作为权重,对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精度  │                             │
│        └───────────┘                             │
└─────────────────────────────────────────────────┘

融合定位的工作流程:

  1. WiFi扫描:每10-15秒获取一次WiFi定位结果
  2. BLE扫描:每1-3秒获取一次BLE定位结果
  3. 加权融合:WiFi(0.3) + BLE(0.7) = 融合位置
  4. PDR补充:在WiFi/BLE扫描间隔期间,使用PDR推算位置
  5. 位置校准:每次获得新的融合位置时,重置PDR的起点
  6. 地图匹配:将最终位置吸附到最近的走廊路径上

这种融合方案的预期精度:

定位源精度更新频率
WiFi3-5m10-15s
BLE1-3m1-3s
PDR0.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色系不一致P2v1.1统一色系映射
步行速度假设偏低P2v1.1校准像素-实际距离比例
无障碍访问不足P1v1.1添加语义化标注和VoiceOver支持
POI数据硬编码P2v1.2从JSON配置文件加载
无路径动画P3v1.1添加路径绘制动画
地图不可缩放P2v1.2添加手势缩放和平移
无离线地图P3v2.0预缓存地图数据
POI无详情页P2v1.1添加POI点击展开详情

附录

A. 术语表

术语英文说明
POIPoint of Interest兴趣点,地图上的标记位置
RSSIReceived Signal Strength Indicator接收信号强度指示
BSSIDBasic Service Set IdentifierAP的MAC地址标识
KNNK-Nearest NeighborK最邻近算法
BLEBluetooth Low Energy低功耗蓝牙
UWBUltra-Wideband超宽带
PDRPedestrian Dead Reckoning行人航位推算
TWRTwo-Way Ranging双向测距
TDoATime Difference of Arrival到达时间差
ToFTime of Flight飞行时间
TTSText-to-Speech文本转语音
ARAugmented Reality增强现实
DPRDevice 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.ets40POI搜索+路径计算+时间估算
HospitalData.ets38POI数据+路径数据+地图参数
HospitalMapView.ets97Canvas 2D地图渲染
HospitalNavPage.ets~60页面交互+状态管理
总计~235室内导航MVP
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐