HarmonyOS开发:室内导航与路径规划
HarmonyOS开发:室内导航与路径规划
核心要点:本文深入讲解基于HarmonyOS的室内导航与路径规划技术,涵盖室内地图数据模型、A*与Dijkstra路径规划算法、导航状态机设计、航位推算(PDR)辅助导航,以及完整的ArkTS代码实现。将定位能力转化为实用的室内导航服务。
| 项目 | 说明 |
|---|---|
| 开发语言 | ArkTS |
| 核心API | Canvas绘制、@ohos.sensor(PDR传感器) |
| 导航精度 | 1-3米(融合定位) |
| 官方文档 | 传感器开发指导 |
一、背景与动机
1.1 从定位到导航的跨越
前几篇文章解决了"我在哪里"的问题,但室内定位的最终价值在于"如何到达目的地"。室内导航是将定位能力转化为用户价值的关键环节——用户需要的不是一组坐标数字,而是一条清晰的行走路线和实时的导航指引。
1.2 室内导航 vs 室外导航
| 对比维度 | 室内导航 | 室外导航 |
|---|---|---|
| 地图数据 | 建筑内部平面图 | 道路网络 |
| 定位源 | 蓝牙/WiFi/UWB | GPS/BDS |
| 路径约束 | 走廊、门、电梯 | 道路、交通规则 |
| 垂直维度 | 多楼层切换 | 基本忽略 |
| 精度要求 | 1-3m | 5-10m |
| 更新频率 | 实时(1-2Hz) | 中等(1Hz) |
| 地图更新 | 频繁(装修/布局变化) | 较少 |
1.3 室内导航系统架构
flowchart TB
subgraph 输入层["输入层"]
direction LR
A1["室内定位<br/>蓝牙/WiFi/UWB"]
A2["传感器<br/>PDR/陀螺仪"]
A3["用户交互<br/>目的地选择"]
end
subgraph 核心层["核心引擎"]
direction TB
B1["室内地图引擎"] --> B2["路径规划引擎"]
B2 --> B3["导航状态机"]
B3 --> B4["航向推算"]
B4 --> B5["指令生成"]
end
subgraph 输出层["输出层"]
direction LR
C1["地图渲染"]
C2["语音播报"]
C3["振动提示"]
end
A1 --> B4
A2 --> B4
A3 --> B2
B1 --> B2
B5 --> C1
B5 --> C2
B5 --> C3
classDef input fill:#533483,stroke:#e94560,color:#eee,stroke-width:2px
classDef core fill:#16213e,stroke:#0f3460,color:#eee,stroke-width:2px
classDef output fill:#0f3460,stroke:#4ade80,color:#eee,stroke-width:2px
class A1,A2,A3 input
class B1,B2,B3,B4,B5 core
class C1,C2,C3 output
二、核心原理
2.1 室内地图数据模型
室内地图是导航的基础,需要表达建筑内部的拓扑结构和几何信息。
关键数据结构:
- 楼层平面图(FloorPlan):每层的几何轮廓和背景图
- 可通行区域(WalkableArea):走廊、大厅等可步行区域
- 不可通行区域(Obstacle):墙壁、柱子、家具等障碍物
- 导航节点(NavNode):路径网络的节点(交叉口、门口等)
- 导航边(NavEdge):节点之间的可通行路径
- 兴趣点(POI):商店、办公室、卫生间等
- 设施点(Facility):电梯、楼梯、扶梯等楼层连接点
2.2 路径规划算法
室内路径规划的核心是在导航图(NavGraph)上寻找最短路径。
Dijkstra算法:经典最短路径算法,保证全局最优,但搜索范围大。
A*算法:在Dijkstra基础上加入启发式函数,搜索效率显著提升。
其中为从起点到节点的实际代价,为从节点到终点的启发式估计代价。
室内导航中,通常使用欧氏距离:
多楼层路径规划:将楼梯/电梯作为楼层间的"传送门"边,统一建模为图搜索问题。
2.3 导航状态机
导航过程可建模为有限状态机,管理导航的各个阶段:
stateDiagram-v2
[*] --> Idle: 用户打开应用
Idle --> Planning: 选择目的地
Planning --> Guiding: 路径计算完成
Planning --> Idle: 无可达路径
Guiding --> Rerouting: 偏离路线
Rerouting --> Guiding: 重新规划成功
Guiding --> Arrived: 到达目的地
Guiding --> Paused: 用户暂停
Paused --> Guiding: 用户继续
Arrived --> Idle: 导航结束
classDef idle fill:#1a1a2e,stroke:#94a3b8,color:#eee
classDef active fill:#16213e,stroke:#0f3460,color:#eee
classDef alert fill:#0f3460,stroke:#e94560,color:#eee
classDef success fill:#0f3460,stroke:#4ade80,color:#eee
2.4 航位推算(PDR)
PDR(Pedestrian Dead Reckoning,行人航位推算)利用手机传感器推算行人位置变化,辅助定位系统:
步频检测:通过加速度计检测步行周期
步长估计:根据步频和加速度特征估计步长
航向估计:通过陀螺仪和磁力计估计行走方向
其中为步长,为航向角。
PDR的优势是短期精度高(不受信号遮挡影响),但误差会累积,必须与绝对定位(蓝牙/WiFi/UWB)融合使用。
三、代码实战
3.1 室内地图数据模型
// IndoorMapModel.ets - 室内地图数据模型
// 导航节点类型
enum NavNodeType {
INTERSECTION = 'intersection', // 交叉口
DOOR = 'door', // 门口
ELEVATOR = 'elevator', // 电梯
STAIRS = 'stairs', // 楼梯
ESCALATOR = 'escalator', // 扶梯
POI = 'poi', // 兴趣点
WAYPOINT = 'waypoint', // 路径点
}
// 导航节点
interface NavNode {
id: string;
type: NavNodeType;
x: number; // 像素坐标X
y: number; // 像素坐标Y
floor: number; // 楼层
building: string; // 建筑
name?: string; // 名称
connectedFloors?: number[]; // 连接的楼层(电梯/楼梯)
poiId?: string; // 关联的POI ID
}
// 导航边属性
interface NavEdgeAttr {
length: number; // 长度(米)
width: number; // 宽度(米)
isAccessible: boolean; // 是否可通行
isWheelchair: boolean; // 轮椅可通行
speedFactor: number; // 速度系数(1.0=正常, <1.0=慢)
}
// 导航边
interface NavEdge {
fromId: string;
toId: string;
attr: NavEdgeAttr;
}
// 兴趣点
interface POI {
id: string;
name: string;
category: string; // 类别:shop/office/restroom/food...
x: number;
y: number;
floor: number;
building: string;
navNodeId: string; // 关联的导航节点ID
description?: string;
icon?: string;
}
// 楼层平面图
interface FloorPlan {
building: string;
floor: number;
name: string; // "B1", "1F", "2F"...
width: number; // 像素宽度
height: number; // 像素高度
scale: number; // 像素/米 比例尺
nodes: NavNode[];
edges: NavEdge[];
pois: POI[];
obstacles: Array<{ // 障碍物多边形
points: Array<{ x: number; y: number }>;
type: string; // wall/pillar/furniture
}>;
}
// 室内地图
export class IndoorMap {
private floors: Map<string, FloorPlan> = new Map(); // key: "building_floor"
private nodeIndex: Map<string, NavNode> = new Map(); // 快速查找节点
private adjacencyList: Map<string, Array<{
nodeId: string;
edge: NavEdge;
}>> = new Map(); // 邻接表
// 添加楼层平面图
addFloorPlan(plan: FloorPlan): void {
const key = `${plan.building}_${plan.floor}`;
this.floors.set(key, plan);
// 构建索引
for (const node of plan.nodes) {
this.nodeIndex.set(node.id, node);
}
// 构建邻接表
for (const edge of plan.edges) {
if (!this.adjacencyList.has(edge.fromId)) {
this.adjacencyList.set(edge.fromId, []);
}
this.adjacencyList.get(edge.fromId)!.push({
nodeId: edge.toId,
edge
});
// 无向图,添加反向边
if (!this.adjacencyList.has(edge.toId)) {
this.adjacencyList.set(edge.toId, []);
}
this.adjacencyList.get(edge.toId)!.push({
nodeId: edge.fromId,
edge
});
}
console.info(`[IndoorMap] 加载楼层: ${key}, ${plan.nodes.length}节点, ${plan.edges.length}边`);
}
// 获取楼层平面图
getFloorPlan(building: string, floor: number): FloorPlan | undefined {
return this.floors.get(`${building}_${floor}`);
}
// 获取节点
getNode(nodeId: string): NavNode | undefined {
return this.nodeIndex.get(nodeId);
}
// 获取邻接节点
getAdjacentNodes(nodeId: string): Array<{ nodeId: string; edge: NavEdge }> {
return this.adjacencyList.get(nodeId) || [];
}
// 获取所有POI
getAllPOIs(building: string, floor: number): POI[] {
const plan = this.getFloorPlan(building, floor);
return plan?.pois || [];
}
// 按类别搜索POI
searchPOIs(category: string, building: string, floor?: number): POI[] {
const results: POI[] = [];
for (const plan of this.floors.values()) {
if (plan.building !== building) continue;
if (floor !== undefined && plan.floor !== floor) continue;
for (const poi of plan.pois) {
if (poi.category === category) {
results.push(poi);
}
}
}
return results;
}
// 按名称搜索POI
searchPOIByName(name: string, building: string): POI[] {
const results: POI[] = [];
for (const plan of this.floors.values()) {
if (plan.building !== building) continue;
for (const poi of plan.pois) {
if (poi.name.includes(name)) {
results.push(poi);
}
}
}
return results;
}
// 查找最近的导航节点(给定坐标)
findNearestNode(x: number, y: number, floor: number, building: string): NavNode | null {
let nearestNode: NavNode | null = null;
let minDist = Infinity;
const plan = this.getFloorPlan(building, floor);
if (!plan) return null;
for (const node of plan.nodes) {
const dist = Math.sqrt((node.x - x) ** 2 + (node.y - y) ** 2);
if (dist < minDist) {
minDist = dist;
nearestNode = node;
}
}
return nearestNode;
}
// 获取所有楼层
getFloors(building: string): number[] {
const floors: number[] = [];
for (const key of this.floors.keys()) {
if (key.startsWith(`${building}_`)) {
floors.push(parseInt(key.split('_')[1]));
}
}
return floors.sort((a, b) => a - b);
}
}
3.2 A*路径规划算法
// PathPlanner.ets - A*路径规划引擎
import { IndoorMap, NavNode, NavEdge } from './IndoorMapModel';
// 路径段
interface PathSegment {
fromNode: NavNode;
toNode: NavNode;
edge: NavEdge;
direction: string; // 方向描述
distance: number; // 距离(米)
isFloorChange: boolean; // 是否楼层切换
}
// 导航路径
interface NavigationPath {
segments: PathSegment[];
totalDistance: number; // 总距离(米)
estimatedTime: number; // 预计时间(秒)
floorChanges: number; // 楼层切换次数
startNode: NavNode;
endNode: NavNode;
waypoints: Array<{ x: number; y: number; floor: number }>; // 路径点坐标
}
// A*搜索节点
interface AStarNode {
nodeId: string;
gCost: number; // 实际代价
hCost: number; // 启发式代价
fCost: number; // 总代价
parentId: string | null;
floor: number;
building: string;
}
export class PathPlanner {
private map: IndoorMap;
private averageWalkSpeed: number = 1.2; // 平均步行速度 m/s
constructor(map: IndoorMap) {
this.map = map;
}
// A*路径规划
planPath(
startNodeId: string,
endNodeId: string,
options?: { avoidStairs?: boolean; wheelchair?: boolean }
): NavigationPath | null {
const startNode = this.map.getNode(startNodeId);
const endNode = this.map.getNode(endNodeId);
if (!startNode || !endNode) {
console.error('[PathPlanner] 起点或终点节点不存在');
return null;
}
// A*搜索
const openSet: Map<string, AStarNode> = new Map();
const closedSet: Set<string> = new Set();
const startAStar: AStarNode = {
nodeId: startNodeId,
gCost: 0,
hCost: this.heuristic(startNode, endNode),
fCost: this.heuristic(startNode, endNode),
parentId: null,
floor: startNode.floor,
building: startNode.building
};
openSet.set(startNodeId, startAStar);
let iterations = 0;
const maxIterations = 10000;
while (openSet.size > 0 && iterations < maxIterations) {
iterations++;
// 选择fCost最小的节点
let currentId = '';
let minFCost = Infinity;
for (const [id, node] of openSet) {
if (node.fCost < minFCost || (node.fCost === minFCost && node.hCost < (openSet.get(currentId)?.hCost ?? Infinity))) {
minFCost = node.fCost;
currentId = id;
}
}
const current = openSet.get(currentId)!;
// 到达终点
if (currentId === endNodeId) {
return this.reconstructPath(current, startNodeId, endNodeId);
}
// 移到关闭集
openSet.delete(currentId);
closedSet.add(currentId);
// 遍历邻接节点
const neighbors = this.map.getAdjacentNodes(currentId);
for (const neighbor of neighbors) {
if (closedSet.has(neighbor.nodeId)) continue;
// 检查可通行性
if (!neighbor.edge.attr.isAccessible) continue;
// 轮椅模式检查
if (options?.wheelchair && !neighbor.edge.attr.isWheelchair) continue;
// 避免楼梯检查
const neighborNode = this.map.getNode(neighbor.nodeId);
if (!neighborNode) continue;
if (options?.avoidStairs && neighborNode.type === 'stairs') continue;
// 计算代价
const edgeCost = neighbor.edge.attr.length / neighbor.edge.attr.speedFactor;
// 楼层切换额外代价(模拟等电梯/走楼梯的时间)
let floorChangeCost = 0;
if (neighborNode.floor !== current.floor) {
floorChangeCost = neighborNode.type === 'elevator' ? 30 : 45; // 电梯30秒,楼梯45秒
}
const tentativeG = current.gCost + edgeCost + floorChangeCost;
const existingNeighbor = openSet.get(neighbor.nodeId);
if (existingNeighbor && tentativeG >= existingNeighbor.gCost) {
continue;
}
const hCost = this.heuristic(neighborNode, endNode);
const aStarNode: AStarNode = {
nodeId: neighbor.nodeId,
gCost: tentativeG,
hCost,
fCost: tentativeG + hCost,
parentId: currentId,
floor: neighborNode.floor,
building: neighborNode.building
};
openSet.set(neighbor.nodeId, aStarNode);
}
}
console.warn('[PathPlanner] 未找到路径');
return null;
}
// 启发式函数(欧氏距离)
private heuristic(from: NavNode, to: NavNode): number {
const dx = from.x - to.x;
const dy = from.y - to.y;
const floorDiff = Math.abs(from.floor - to.floor);
// 同楼层用欧氏距离,跨楼层加上楼层距离
const planarDist = Math.sqrt(dx * dx + dy * dy);
const floorDist = floorDiff * 3.5; // 每层约3.5米高度
return Math.sqrt(planarDist * planarDist + floorDist * floorDist);
}
// 重建路径
private reconstructPath(
endNode: AStarNode,
startNodeId: string,
endNodeId: string
): NavigationPath {
const path: AStarNode[] = [];
let current: AStarNode | undefined = endNode;
while (current) {
path.unshift(current);
if (current.nodeId === startNodeId) break;
const parentId = current.parentId;
if (!parentId) break;
// 需要从closedSet或openSet中找到父节点
// 简化处理:直接从path中查找
current = undefined; // 实际实现需要维护完整的节点映射
}
// 构建路径段
const segments: PathSegment[] = [];
const waypoints: Array<{ x: number; y: number; floor: number }> = [];
let totalDistance = 0;
let floorChanges = 0;
for (let i = 0; i < path.length - 1; i++) {
const fromNode = this.map.getNode(path[i].nodeId)!;
const toNode = this.map.getNode(path[i + 1].nodeId)!;
// 查找边
const adjacents = this.map.getAdjacentNodes(fromNode.id);
const edge = adjacents.find(a => a.nodeId === toNode.id)?.edge;
if (edge) {
const isFloorChange = fromNode.floor !== toNode.floor;
if (isFloorChange) floorChanges++;
const direction = this.calculateDirection(fromNode, toNode);
const distance = edge.attr.length;
segments.push({
fromNode,
toNode,
edge,
direction,
distance,
isFloorChange
});
totalDistance += distance;
}
waypoints.push({ x: fromNode.x, y: fromNode.y, floor: fromNode.floor });
}
// 添加最后一个点
const lastNode = this.map.getNode(path[path.length - 1].nodeId)!;
waypoints.push({ x: lastNode.x, y: lastNode.y, floor: lastNode.floor });
const estimatedTime = totalDistance / this.averageWalkSpeed;
return {
segments,
totalDistance: Math.round(totalDistance * 10) / 10,
estimatedTime: Math.round(estimatedTime),
floorChanges,
startNode: this.map.getNode(startNodeId)!,
endNode: this.map.getNode(endNodeId)!,
waypoints
};
}
// 计算方向描述
private calculateDirection(from: NavNode, to: NavNode): string {
const dx = to.x - from.x;
const dy = to.y - from.y;
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
if (angle >= -22.5 && angle < 22.5) return '向东';
if (angle >= 22.5 && angle < 67.5) return '向东南';
if (angle >= 67.5 && angle < 112.5) return '向南';
if (angle >= 112.5 && angle < 157.5) return '向西南';
if (angle >= 157.5 || angle < -157.5) return '向西';
if (angle >= -157.5 && angle < -112.5) return '向西北';
if (angle >= -112.5 && angle < -67.5) return '向北';
if (angle >= -67.5 && angle < -22.5) return '向东北';
return '直行';
}
// 多楼层路径规划
planCrossFloorPath(
startNodeId: string,
endNodeId: string,
options?: { preferElevator?: boolean; wheelchair?: boolean }
): NavigationPath | null {
const startNode = this.map.getNode(startNodeId);
const endNode = this.map.getNode(endNodeId);
if (!startNode || !endNode) return null;
// 同楼层直接规划
if (startNode.floor === endNode.floor && startNode.building === endNode.building) {
return this.planPath(startNodeId, endNodeId, options);
}
// 跨楼层:查找楼层连接点(电梯/楼梯)
return this.planPath(startNodeId, endNodeId, {
...options,
avoidStairs: options?.preferElevator
});
}
}
3.3 导航状态机与指令生成
// NavigationEngine.ets - 导航状态机与指令生成
import { IndoorMap, NavNode } from './IndoorMapModel';
import { PathPlanner, NavigationPath, PathSegment } from './PathPlanner';
// 导航状态
enum NavState {
IDLE = 'idle',
PLANNING = 'planning',
GUIDING = 'guiding',
REROUTING = 'rerouting',
PAUSED = 'paused',
ARRIVED = 'arrived',
}
// 导航指令
interface NavigationInstruction {
type: 'turn' | 'straight' | 'floor_change' | 'arrive' | 'reroute';
text: string; // 指令文本
detailText: string; // 详细描述
distanceToNext: number; // 到下一指令的距离(米)
icon: string; // 指令图标标识
floor: number; // 当前楼层
isUrgent: boolean; // 是否需要立即执行
}
// 导航进度
interface NavigationProgress {
state: NavState;
currentSegmentIndex: number;
completedDistance: number;
remainingDistance: number;
totalDistance: number;
progressPercent: number;
currentInstruction: NavigationInstruction | null;
nextInstruction: NavigationInstruction | null;
estimatedArrivalTime: number; // 秒
offRouteDistance: number; // 偏离距离(米)
}
export class NavigationEngine {
private map: IndoorMap;
private planner: PathPlanner;
private state: NavState = NavState.IDLE;
private currentPath: NavigationPath | null = null;
private currentSegmentIndex: number = 0;
private userPosition: { x: number; y: number; floor: number } | null = null;
private offRouteThreshold: number = 5.0; // 偏离阈值(米)
private onStateChange?: (state: NavState) => void;
private onInstructionUpdate?: (instruction: NavigationInstruction) => void;
private onProgressUpdate?: (progress: NavigationProgress) => void;
constructor(map: IndoorMap) {
this.map = map;
this.planner = new PathPlanner(map);
}
// 设置回调
setCallbacks(callbacks: {
onStateChange?: (state: NavState) => void;
onInstructionUpdate?: (instruction: NavigationInstruction) => void;
onProgressUpdate?: (progress: NavigationProgress) => void;
}): void {
this.onStateChange = callbacks.onStateChange;
this.onInstructionUpdate = callbacks.onInstructionUpdate;
this.onProgressUpdate = callbacks.onProgressUpdate;
}
// 开始导航
startNavigation(
startNodeId: string,
endNodeId: string,
options?: { preferElevator?: boolean; wheelchair?: boolean }
): boolean {
this.setState(NavState.PLANNING);
const path = this.planner.planCrossFloorPath(startNodeId, endNodeId, options);
if (!path) {
this.setState(NavState.IDLE);
console.error('[NavEngine] 路径规划失败');
return false;
}
this.currentPath = path;
this.currentSegmentIndex = 0;
this.setState(NavState.GUIDING);
// 发送第一条指令
this.emitCurrentInstruction();
console.info(`[NavEngine] 导航开始: ${path.totalDistance}m, 约${path.estimatedTime}秒`);
return true;
}
// 更新用户位置
updatePosition(x: number, y: number, floor: number): void {
this.userPosition = { x, y, floor };
if (this.state !== NavState.GUIDING || !this.currentPath) {
return;
}
// 检查是否偏离路线
const offRouteDistance = this.calculateOffRouteDistance(x, y, floor);
if (offRouteDistance > this.offRouteThreshold) {
this.handleOffRoute();
return;
}
// 更新路径段进度
this.updateSegmentProgress(x, y, floor);
// 发送进度更新
this.emitProgress(offRouteDistance);
}
// 计算偏离路线距离
private calculateOffRouteDistance(x: number, y: number, floor: number): number {
if (!this.currentPath) return Infinity;
// 检查当前楼层是否匹配
const currentSegment = this.currentPath.segments[this.currentSegmentIndex];
if (currentSegment && currentSegment.toNode.floor !== floor) {
return this.offRouteThreshold + 1; // 楼层不匹配视为偏离
}
// 计算到当前路径段的最短距离
let minDist = Infinity;
for (let i = Math.max(0, this.currentSegmentIndex - 1);
i < Math.min(this.currentPath.segments.length, this.currentSegmentIndex + 3);
i++) {
const seg = this.currentPath.segments[i];
const dist = this.pointToSegmentDistance(
x, y,
seg.fromNode.x, seg.fromNode.y,
seg.toNode.x, seg.toNode.y
);
minDist = Math.min(minDist, dist);
}
return minDist;
}
// 点到线段的距离
private pointToSegmentDistance(
px: number, py: number,
ax: number, ay: number,
bx: number, by: number
): number {
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
return Math.sqrt((px - ax) ** 2 + (py - ay) ** 2);
}
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const projX = ax + t * dx;
const projY = ay + t * dy;
return Math.sqrt((px - projX) ** 2 + (py - projY) ** 2);
}
// 更新路径段进度
private updateSegmentProgress(x: number, y: number, floor: number): void {
if (!this.currentPath) return;
const segment = this.currentPath.segments[this.currentSegmentIndex];
if (!segment) return;
// 计算到当前段终点的距离
const distToEnd = Math.sqrt(
(x - segment.toNode.x) ** 2 + (y - segment.toNode.y) ** 2
);
// 如果接近当前段终点,切换到下一段
if (distToEnd < 3.0) { // 3米内认为已到达
this.currentSegmentIndex++;
if (this.currentSegmentIndex >= this.currentPath.segments.length) {
// 到达目的地
this.setState(NavState.ARRIVED);
this.emitInstruction({
type: 'arrive',
text: '您已到达目的地',
detailText: this.currentPath.endNode.name || '目的地',
distanceToNext: 0,
icon: 'flag',
floor,
isUrgent: true
});
return;
}
// 发送新指令
this.emitCurrentInstruction();
}
}
// 处理偏离路线
private handleOffRoute(): void {
if (this.state === NavState.REROUTING) return;
this.setState(NavState.REROUTING);
this.emitInstruction({
type: 'reroute',
text: '您已偏离路线,正在重新规划...',
detailText: '请稍候',
distanceToNext: 0,
icon: 'reroute',
floor: this.userPosition?.floor ?? 1,
isUrgent: true
});
// 重新规划路径
if (this.userPosition && this.currentPath) {
const nearestNode = this.map.findNearestNode(
this.userPosition.x,
this.userPosition.y,
this.userPosition.floor,
this.currentPath.endNode.building
);
if (nearestNode) {
const newPath = this.planner.planPath(
nearestNode.id,
this.currentPath.endNode.id
);
if (newPath) {
this.currentPath = newPath;
this.currentSegmentIndex = 0;
this.setState(NavState.GUIDING);
this.emitCurrentInstruction();
}
}
}
}
// 生成当前导航指令
private emitCurrentInstruction(): void {
if (!this.currentPath) return;
const segment = this.currentPath.segments[this.currentSegmentIndex];
if (!segment) return;
const instruction = this.generateInstruction(segment);
this.emitInstruction(instruction);
}
// 生成导航指令
private generateInstruction(segment: PathSegment): NavigationInstruction {
if (segment.isFloorChange) {
const facilityType = segment.toNode.type;
const facilityName = facilityType === 'elevator' ? '电梯' :
facilityType === 'stairs' ? '楼梯' : '扶梯';
const targetFloor = segment.toNode.floor;
const floorName = targetFloor < 0 ? `B${Math.abs(targetFloor)}层` : `${targetFloor}层`;
return {
type: 'floor_change',
text: `乘坐${facilityName}前往${floorName}`,
detailText: `到达${floorName}后继续前行`,
distanceToNext: segment.distance,
icon: facilityType,
floor: segment.fromNode.floor,
isUrgent: false
};
}
// 计算转弯方向
let turnDirection = 'straight';
if (this.currentSegmentIndex > 0) {
const prevSegment = this.currentPath!.segments[this.currentSegmentIndex - 1];
turnDirection = this.calculateTurnDirection(prevSegment, segment);
}
const directionMap: Record<string, string> = {
'left': '左转',
'right': '右转',
'slight_left': '稍向左转',
'slight_right': '稍向右转',
'sharp_left': '急左转',
'sharp_right': '急右转',
'straight': '直行',
'uturn': '掉头'
};
const turnText = directionMap[turnDirection] || '直行';
const distanceText = segment.distance >= 100 ?
`${Math.round(segment.distance)}米` :
`${Math.round(segment.distance * 10) / 10}米`;
return {
type: turnDirection === 'straight' ? 'straight' : 'turn',
text: `${turnText},前行${distanceText}`,
detailText: segment.toNode.name ?
`前往${segment.toNode.name}` : `沿走廊前行`,
distanceToNext: segment.distance,
icon: turnDirection,
floor: segment.fromNode.floor,
isUrgent: false
};
}
// 计算转弯方向
private calculateTurnDirection(prev: PathSegment, current: PathSegment): string {
const prevAngle = Math.atan2(
prev.toNode.y - prev.fromNode.y,
prev.toNode.x - prev.fromNode.x
);
const currAngle = Math.atan2(
current.toNode.y - current.fromNode.y,
current.toNode.x - current.fromNode.x
);
let angleDiff = (currAngle - prevAngle) * 180 / Math.PI;
// 归一化到 -180 ~ 180
while (angleDiff > 180) angleDiff -= 360;
while (angleDiff < -180) angleDiff += 360;
if (Math.abs(angleDiff) < 15) return 'straight';
if (angleDiff >= 15 && angleDiff < 45) return 'slight_right';
if (angleDiff >= 45 && angleDiff < 120) return 'right';
if (angleDiff >= 120) return 'sharp_right';
if (angleDiff <= -15 && angleDiff > -45) return 'slight_left';
if (angleDiff <= -45 && angleDiff > -120) return 'left';
if (angleDiff <= -120) return 'sharp_left';
return 'straight';
}
// 暂停导航
pauseNavigation(): void {
if (this.state === NavState.GUIDING) {
this.setState(NavState.PAUSED);
}
}
// 恢复导航
resumeNavigation(): void {
if (this.state === NavState.PAUSED) {
this.setState(NavState.GUIDING);
}
}
// 停止导航
stopNavigation(): void {
this.currentPath = null;
this.currentSegmentIndex = 0;
this.setState(NavState.IDLE);
}
// 状态变更
private setState(newState: NavState): void {
this.state = newState;
this.onStateChange?.(newState);
}
// 发送指令
private emitInstruction(instruction: NavigationInstruction): void {
this.onInstructionUpdate?.(instruction);
}
// 发送进度
private emitProgress(offRouteDistance: number): void {
if (!this.currentPath) return;
const completedDistance = this.currentPath.segments
.slice(0, this.currentSegmentIndex)
.reduce((sum, seg) => sum + seg.distance, 0);
const remainingDistance = this.currentPath.totalDistance - completedDistance;
const progressPercent = (completedDistance / this.currentPath.totalDistance) * 100;
const currentInstruction = this.currentSegmentIndex < this.currentPath.segments.length ?
this.generateInstruction(this.currentPath.segments[this.currentSegmentIndex]) : null;
const nextInstruction = (this.currentSegmentIndex + 1) < this.currentPath.segments.length ?
this.generateInstruction(this.currentPath.segments[this.currentSegmentIndex + 1]) : null;
this.onProgressUpdate?.({
state: this.state,
currentSegmentIndex: this.currentSegmentIndex,
completedDistance: Math.round(completedDistance * 10) / 10,
remainingDistance: Math.round(remainingDistance * 10) / 10,
totalDistance: this.currentPath.totalDistance,
progressPercent: Math.round(progressPercent),
currentInstruction,
nextInstruction,
estimatedArrivalTime: Math.round(remainingDistance / 1.2),
offRouteDistance: Math.round(offRouteDistance * 10) / 10
});
}
// 获取当前状态
getState(): NavState {
return this.state;
}
// 获取当前路径
getCurrentPath(): NavigationPath | null {
return this.currentPath;
}
}
3.4 导航页面UI
// pages/IndoorNavigationPage.ets - 室内导航页面
import { IndoorMap, FloorPlan, POI } from '../service/IndoorMapModel';
import { NavigationEngine, NavState, NavigationInstruction, NavigationProgress } from '../service/NavigationEngine';
@Entry
@Component
struct IndoorNavigationPage {
@State navState: NavState = NavState.IDLE;
@State currentInstruction: NavigationInstruction | null = null;
@State progress: NavigationProgress | null = null;
@State searchQuery: string = '';
@State searchResults: POI[] = [];
@State currentFloor: number = 1;
@State destinationName: string = '';
private map: IndoorMap = new IndoorMap();
private navEngine: NavigationEngine = new NavigationEngine(this.map);
aboutToAppear(): void {
this.loadMapData();
this.setupNavCallbacks();
}
// 加载地图数据(模拟数据)
private loadMapData(): void {
const plan: FloorPlan = {
building: 'A栋',
floor: 1,
name: '1F',
width: 800,
height: 600,
scale: 20, // 20像素/米
nodes: [
{ id: 'n1', type: NavNodeType.WAYPOINT, x: 100, y: 300, floor: 1, building: 'A栋' },
{ id: 'n2', type: NavNodeType.INTERSECTION, x: 300, y: 300, floor: 1, building: 'A栋', name: '走廊交叉口' },
{ id: 'n3', type: NavNodeType.INTERSECTION, x: 500, y: 300, floor: 1, building: 'A栋', name: '中央大厅' },
{ id: 'n4', type: NavNodeType.POI, x: 300, y: 150, floor: 1, building: 'A栋', name: '咖啡厅', poiId: 'poi1' },
{ id: 'n5', type: NavNodeType.POI, x: 500, y: 150, floor: 1, building: 'A栋', name: '书店', poiId: 'poi2' },
{ id: 'n6', type: NavNodeType.ELEVATOR, x: 700, y: 300, floor: 1, building: 'A栋', name: '电梯', connectedFloors: [1, 2, 3] },
{ id: 'n7', type: NavNodeType.POI, x: 500, y: 450, floor: 1, building: 'A栋', name: '卫生间', poiId: 'poi3' },
],
edges: [
{ fromId: 'n1', toId: 'n2', attr: { length: 10, width: 2, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
{ fromId: 'n2', toId: 'n3', attr: { length: 10, width: 3, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
{ fromId: 'n2', toId: 'n4', attr: { length: 7.5, width: 2, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
{ fromId: 'n3', toId: 'n5', attr: { length: 7.5, width: 2, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
{ fromId: 'n3', toId: 'n6', attr: { length: 10, width: 3, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
{ fromId: 'n3', toId: 'n7', attr: { length: 7.5, width: 2, isAccessible: true, isWheelchair: true, speedFactor: 1.0 } },
],
pois: [
{ id: 'poi1', name: '咖啡厅', category: 'food', x: 300, y: 150, floor: 1, building: 'A栋', navNodeId: 'n4' },
{ id: 'poi2', name: '书店', category: 'shop', x: 500, y: 150, floor: 1, building: 'A栋', navNodeId: 'n5' },
{ id: 'poi3', name: '卫生间', category: 'restroom', x: 500, y: 450, floor: 1, building: 'A栋', navNodeId: 'n7' },
],
obstacles: []
};
this.map.addFloorPlan(plan);
}
// 设置导航回调
private setupNavCallbacks(): void {
this.navEngine.setCallbacks({
onStateChange: (state: NavState) => {
this.navState = state;
},
onInstructionUpdate: (instruction: NavigationInstruction) => {
this.currentInstruction = instruction;
},
onProgressUpdate: (progress: NavigationProgress) => {
this.progress = progress;
}
});
}
build() {
Column() {
// 顶部状态栏
this.TopStatusBar()
// 地图区域
this.MapArea()
// 导航指令卡片
if (this.navState === NavState.GUIDING && this.currentInstruction) {
this.InstructionCard()
}
// 搜索与目的地(非导航状态)
if (this.navState === NavState.IDLE) {
this.SearchAndDestination()
}
// 进度条(导航中)
if (this.progress && this.navState === NavState.GUIDING) {
this.ProgressBar()
}
}
.width('100%')
.height('100%')
.backgroundColor('#0a0a1a')
}
@Builder
TopStatusBar() {
Row() {
Text('室内导航')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#ffffff')
Blank()
// 楼层选择
Row() {
ForEach([1, 2, 3], (floor: number) => {
Text(`${floor}F`)
.fontSize(13)
.fontColor(this.currentFloor === floor ? '#4ade80' : '#94a3b8')
.fontWeight(this.currentFloor === floor ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor(this.currentFloor === floor ? 'rgba(74, 222, 128, 0.15)' : 'transparent')
.onClick(() => { this.currentFloor = floor; })
})
}
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 8 })
}
@Builder
MapArea() {
Column() {
// 地图占位(实际应使用Canvas绘制)
Column() {
Text('室内地图')
.fontSize(16)
.fontColor('#64748b')
Text(`${this.currentFloor}F 平面图`)
.fontSize(12)
.fontColor('#475569')
.margin({ top: 4 })
}
.width('100%')
.height(300)
.justifyContent(FlexAlign.Center)
.borderRadius(16)
.backgroundColor('rgba(30, 41, 59, 0.5)')
.border({ width: 1, color: 'rgba(148, 163, 184, 0.1)' })
.margin({ left: 16, right: 16 })
}
}
@Builder
InstructionCard() {
Column() {
Row() {
// 指令图标
Column() {
Text(this.getInstructionIcon(this.currentInstruction!.icon))
.fontSize(28)
.fontColor('#4ade80')
}
.width(50)
.justifyContent(FlexAlign.Center)
// 指令文本
Column() {
Text(this.currentInstruction!.text)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#ffffff')
Text(this.currentInstruction!.detailText)
.fontSize(13)
.fontColor('#94a3b8')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
// 距离
Column() {
Text(`${this.currentInstruction!.distanceToNext.toFixed(0)}m`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#4ade80')
}
.width(60)
.alignItems(HorizontalAlign.End)
}
.width('100%')
}
.width('100%')
.padding(16)
.margin({ left: 16, right: 16, top: 12 })
.borderRadius(16)
.backgroundColor('rgba(30, 41, 59, 0.9)')
.backdropBlur(20)
.border({ width: 1, color: 'rgba(74, 222, 128, 0.3)' })
}
@Builder
SearchAndDestination() {
Column() {
// 搜索框
Row() {
TextInput({ placeholder: '搜索目的地...', text: this.searchQuery })
.layoutWeight(1)
.height(44)
.fontSize(14)
.fontColor('#ffffff')
.backgroundColor('rgba(30, 41, 59, 0.8)')
.borderRadius(12)
.onChange((value: string) => {
this.searchQuery = value;
this.searchResults = this.map.searchPOIByName(value, 'A栋');
})
}
.width('100%')
.margin({ top: 16 })
// 搜索结果
if (this.searchResults.length > 0) {
List() {
ForEach(this.searchResults, (poi: POI) => {
ListItem() {
Row() {
Text(poi.name)
.fontSize(14)
.fontColor('#e2e8f0')
Blank()
Text(poi.category)
.fontSize(12)
.fontColor('#64748b')
}
.width('100%')
.padding(12)
.borderRadius(10)
.backgroundColor('rgba(30, 41, 59, 0.5)')
.onClick(() => {
this.startNavToPOI(poi);
})
}
}, (poi: POI) => poi.id)
}
.width('100%')
.height(150)
.margin({ top: 8 })
}
// 快捷分类
Row() {
this.CategoryButton('🍽️ 餐饮', 'food')
this.CategoryButton('🛍️ 商店', 'shop')
this.CategoryButton('🚻 卫生间', 'restroom')
this.CategoryButton('🛗 电梯', 'elevator')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.margin({ top: 12 })
}
.padding({ left: 16, right: 16 })
}
@Builder
CategoryButton(label: string, category: string) {
Column() {
Text(label)
.fontSize(12)
.fontColor('#e2e8f0')
}
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor('rgba(30, 41, 59, 0.6)')
.border({ width: 1, color: 'rgba(148, 163, 184, 0.1)' })
.onClick(() => {
this.searchResults = this.map.searchPOIs(category, 'A栋', this.currentFloor);
})
}
@Builder
ProgressBar() {
Column() {
// 进度条
Row() {
Row()
.width(`${this.progress!.progressPercent}%`)
.height(4)
.borderRadius(2)
.backgroundColor('#4ade80')
}
.width('100%')
.height(4)
.borderRadius(2)
.backgroundColor('rgba(148, 163, 184, 0.2)')
Row() {
Text(`剩余 ${this.progress!.remainingDistance}m`)
.fontSize(12)
.fontColor('#94a3b8')
Blank()
Text(`约 ${this.progress!.estimatedArrivalTime}秒`)
.fontSize(12)
.fontColor('#94a3b8')
}
.width('100%')
.margin({ top: 6 })
// 停止按钮
Button('结束导航')
.width('100%')
.height(40)
.fontSize(14)
.fontColor('#ffffff')
.backgroundColor('#dc2626')
.borderRadius(10)
.margin({ top: 8 })
.onClick(() => {
this.navEngine.stopNavigation();
})
}
.padding({ left: 16, right: 16, top: 12, bottom: 16 })
}
// 辅助方法
private getInstructionIcon(icon: string): string {
const iconMap: Record<string, string> = {
'straight': '↑',
'left': '←',
'right': '→',
'slight_left': '↖',
'slight_right': '↗',
'sharp_left': '↩',
'sharp_right': '↪',
'elevator': '🛗',
'stairs': '🪜',
'escalator': '↗',
'flag': '🏁',
'reroute': '🔄'
};
return iconMap[icon] || '→';
}
// 开始导航到POI
private startNavToPOI(poi: POI): void {
this.destinationName = poi.name;
// 模拟从入口节点导航到POI节点
this.navEngine.startNavigation('n1', poi.navNodeId);
}
}
四、踩坑与注意事项
4.1 室内地图数据获取
问题:室内地图数据是导航的基础,但获取难度大。
解决方案:
- CAD图纸转换:将建筑CAD图纸转换为导航图
- 人工标注:使用地图编辑工具手动标注节点和边
- 自动提取:使用计算机视觉从平面图中提取可通行区域
- 众包更新:允许用户反馈地图错误,持续修正
4.2 地图坐标系与定位坐标系的统一
问题:地图使用像素坐标,定位输出使用米制坐标,两者必须精确对齐。
解决方案:
- 定义统一的坐标变换参数(原点、旋转角、比例尺)
- 在地图中标注至少3个已知坐标的锚点用于校准
- 使用仿射变换处理非正交的建筑布局
4.3 多楼层导航的复杂性
问题:楼层切换是室内导航特有的复杂场景。
注意事项:
- 电梯等待时间不确定(高峰期可能5-10分钟)
- 楼梯/扶梯的通行时间与行人流量相关
- 某些电梯可能需要刷卡才能到达特定楼层
- 楼层切换时定位信号可能短暂丢失
4.4 导航指令的时机
问题:指令发出过早或过晚都会导致用户走错路。
建议:
- 转弯指令在转弯前5-8米发出
- 楼层切换指令在到达电梯/楼梯前10米发出
- 到达目的地指令在距离3米内发出
- 偏离路线指令在偏离5米后发出
4.5 PDR传感器使用注意
- 步频检测阈值:不同人的步态差异大,需要自适应阈值
- 磁力计干扰:室内金属结构严重干扰磁力计,航向估计需以陀螺仪为主
- 手机持握方式:手持、口袋、通话等不同持握方式影响传感器数据
- 传感器采样率:建议加速度计50Hz以上,陀螺仪100Hz以上
五、HarmonyOS 6适配
5.1 传感器API增强
// HarmonyOS 6 传感器融合
import { sensor } from '@kit.SensorServiceKit';
// PDR步频检测
sensor.on(sensor.SensorType.ACCELEROMETER, (data: sensor.AccelerometerResponse) => {
// 使用峰值检测算法识别步频
processStepDetection(data.x, data.y, data.z);
}, { interval: 20000000 }); // 50Hz
// 陀螺仪航向估计
sensor.on(sensor.SensorType.GYROSCOPE, (data: sensor.GyroscopeResponse) => {
// 积分角速度得到航向变化
updateHeading(data.x, data.y, data.z);
}, { interval: 10000000 }); // 100Hz
5.2 地图渲染优化
HarmonyOS 6增强了Canvas渲染能力:
// HarmonyOS 6 Canvas地图渲染
import { rendering } from '@kit.ArkGraphics2D';
// 使用硬件加速Canvas
const canvas = rendering.getCanvas('navMap');
const ctx = canvas.getContext('2d');
// 绘制路径
ctx.beginPath();
ctx.strokeStyle = '#4ade80';
ctx.lineWidth = 4;
for (const waypoint of path.waypoints) {
ctx.lineTo(waypoint.x, waypoint.y);
}
ctx.stroke();
// 绘制当前位置
ctx.beginPath();
ctx.arc(userX, userY, 8, 0, Math.PI * 2);
ctx.fillStyle = '#3b82f6';
ctx.fill();
5.3 语音导航
// HarmonyOS 6 语音播报
import { textToSpeech } from '@kit.AIKit';
async function speakInstruction(text: string): Promise<void> {
const ttsEngine = textToSpeech.createEngine('zh-CN');
await ttsEngine.speak(text, {
speed: 1.0,
pitch: 1.0,
volume: 0.8,
});
}
六、总结
本文系统讲解了基于HarmonyOS的室内导航与路径规划技术,核心要点回顾:
| 环节 | 关键技术 | 注意事项 |
|---|---|---|
| 地图建模 | NavGraph导航图、POI/设施点 | 坐标系统一、数据获取 |
| 路径规划 | A*算法、多楼层扩展 | 启发式函数选择、楼层切换代价 |
| 导航状态机 | 6种状态、偏离重规划 | 指令时机、偏离阈值 |
| PDR辅助 | 步频检测、步长估计、航向推算 | 传感器噪声、持握方式 |
| 指令生成 | 转弯方向计算、距离描述 | 指令简洁明确、时机恰当 |
室内导航系统设计原则:
- 鲁棒性优先:定位信号丢失时PDR兜底,偏离路线自动重规划
- 用户体验:指令简洁明确,语音+视觉双重反馈
- 可扩展性:地图数据与导航引擎解耦,支持动态加载
- 低功耗:传感器按需启用,后台降低采样率
室内导航是室内定位技术的"最后一公里",将冰冷的坐标数据转化为用户可感知的导航服务。下一篇文章将深入定位精度优化与多源融合,探讨如何将多种定位技术的优势整合,实现更精确、更稳定的室内定位。
- 点赞
- 收藏
- 关注作者
评论(0)