前面幾天已經完成推薦路線的產生,也知道使用者什麼時候可以開始散步,但真正開始散步後,還需要處理幾個問題:
Google Routes 只會提供規劃好的路線,不會根據後續收到的 GPS 自動計算這些結果,因此這些判斷都需要自己處理。
要計算完成率,首先要知道目前 GPS 對應到推薦路線上的哪個位置。
目前從 Google Routes 取得的是 Encoded Polyline,需要先解碼成一系列經緯度座標:
Google Routes
↓
Encoded Polyline
↓
decodePolyline()
↓
Route Points
解碼後的 Route Points 只是折線上的頂點,真正的路線還包含每兩個相鄰 Point 之間的線段。
例如:
P0 ───────────── P1
● GPS
如果 GPS 剛好位於 P0 和 P1 中間,即使使用者實際就在路線上,GPS 到兩個 Point 的距離仍然可能很遠。
因此不能只找最近的 Route Point,而是要比較 GPS 到每一個 Segment 的最短距離:
P0 ───── P1 ───── P2 ───── P3
Segment0:P0 ~ P1
Segment1:P1 ~ P2
Segment2:P2 ~ P3
每個 Segment 會記錄起點、終點、長度,以及它距離整條路線起點的累積距離:
type Point = {
x: number;
y: number;
};
type Segment = {
start: Point;
end: Point;
lengthMeters: number;
cumulativeDistanceMeters: number;
};
type SegmentMatch = {
distanceToRouteMeters: number;
progressMeters: number;
};
這裡的 x、y 不是直接使用經緯度,而是先轉換成以公尺為單位的局部平面座標,後面的向量與距離計算才會使用相同單位。
接著利用向量投影,找出 GPS 在線段上最接近的位置:
const matchSegment = (
gps: Point,
segment: Segment,
): SegmentMatch => {
const ab = subtract(
segment.end,
segment.start,
);
const ap = subtract(
gps,
segment.start,
);
const ab2 = dot(ab, ab);
const t =
ab2 === 0
? 0
: clamp(
dot(ap, ab) / ab2,
0,
1,
);
const closestPoint = {
x: segment.start.x + ab.x * t,
y: segment.start.y + ab.y * t,
};
return {
distanceToRouteMeters:
distance(gps, closestPoint),
progressMeters:
segment.cumulativeDistanceMeters +
segment.lengthMeters * t,
};
};
t 代表最近點位於 Segment 的哪個位置:
t = 0 Segment 起點
t = 0.5 Segment 中間
t = 1 Segment 終點
例如目前 Segment 長 100 公尺,前面已經累積 300 公尺,而 t = 0.65:
progressMeters
= 300 + 100 × 0.65
= 365 公尺
代表目前匹配位置大約位於整條推薦路線的第 365 公尺。
不過,progressMeters 只代表「目前在哪裡」,不能直接當成完成距離。如果使用者從路線中段加入,一開始就可能匹配到第 1000 公尺,但前面的路線並沒有真的走過。
如果每次 GPS 更新都直接搜尋整條路線,在 U 型路線或平行道路上可能會匹配到錯誤位置:
去程 ─────────────────→
● GPS
返程 ←─────────────────
兩段路線可能只相隔幾公尺,GPS 稍微漂移,就可能從去程直接跳到返程。
因此,如果已經有上一次的匹配位置,就優先搜尋附近的 Segment:
const findClosestRouteMatch = (
gps: Point,
segments: Segment[],
lastMatchedIndex: number | null,
) => {
const localMatch =
lastMatchedIndex !== null
? findBestMatch(
gps,
getNearbySegments(
segments,
lastMatchedIndex,
),
)
: null;
if (
localMatch &&
localMatch.distanceToRouteMeters <=
ROUTE_DISTANCE_THRESHOLD_METERS
) {
return localMatch;
}
return findBestMatch(
gps,
segments,
);
};
正常情況下只搜尋上次匹配位置附近的路段,避免 GPS 因為誤差突然跳到很遠的 Segment。
如果附近找不到合理結果,例如 App 從背景恢復後,使用者已經往前走了一段距離,再退回全域搜尋。
最後會取得:
{
segmentIndex: 24,
distanceToRouteMeters: 18,
progressMeters: 365,
}
其中:
segmentIndex:目前最接近哪一段路線distanceToRouteMeters:GPS 距離推薦路線多遠progressMeters:目前匹配位置距離路線起點多遠有了 distanceToRouteMeters 後,就能判斷使用者是否還在推薦路線附近。
目前先設定 25 公尺作為判斷範圍:
const ROUTE_DISTANCE_THRESHOLD_METERS = 25;
const isWithinRoute =
match.distanceToRouteMeters <=
ROUTE_DISTANCE_THRESHOLD_METERS;
但 GPS 本身會有定位誤差,如果只因為某一次定位超過 25 公尺就立刻判定偏離,很容易產生誤判。
因此,我把路線狀態分成三種:
type RouteStatus =
| 'on_route'
| 'possibly_off_route'
| 'off_route';
目前的規則是:
const OFF_ROUTE_CONFIRM_COUNT = 3;
const ON_ROUTE_CONFIRM_COUNT = 2;
也就是:
連續 3 次超出範圍
→ 判定 off_route
已經 off_route 後
連續 2 次回到範圍內
→ 恢復 on_route
核心概念就是利用連續定位結果,而不是只依賴單一 GPS:
if (!isWithinRoute) {
consecutiveOffCount += 1;
consecutiveOnCount = 0;
} else {
consecutiveOnCount += 1;
consecutiveOffCount = 0;
}
if (
consecutiveOffCount >=
OFF_ROUTE_CONFIRM_COUNT
) {
status = 'off_route';
}
if (
consecutiveOnCount >=
ON_ROUTE_CONFIRM_COUNT
) {
status = 'on_route';
}
實際程式中還會處理 possibly_off_route,並在狀態切換時重置計數器。
即使已經偏離 Route Match 仍然會持續執行,才能知道使用者什麼時候重新回到路線。
只有狀態確定為 on_route 時,才會記錄完成路段。
完成率不能直接使用實際散步距離 ÷ 推薦路線距離
因為使用者可能一直在同一路段來回走。散步距離雖然持續增加,卻不代表完成了更多推薦路線。
因此,我改成記錄實際走過哪些 Segment:
const coveredSegments =
new Set<number>();
使用 Set 可以避免同一段路線被重複計算。
原始 Route Point 之間的距離不一定相同。
如果某個 Segment 長達 200 公尺,使用者只經過其中一小部分就把整段算成完成誤差會很大。
因此在開始追蹤前,先將路線切成每段最多約 15 公尺的小 Segment:
const MAX_SEGMENT_LENGTH_METERS = 15;
const splitSegment = (
start: Point,
end: Point,
): Point[] => {
const length =
distance(start, end);
const count = Math.max(
1,
Math.ceil(
length /
MAX_SEGMENT_LENGTH_METERS,
),
);
return Array.from(
{ length: count + 1 },
(_, index) =>
interpolate(
start,
end,
index / count,
),
);
};
例如原本一段長 40 公尺:
0m ─────────────── 40m
會被切成:
0 ───── 13.3 ───── 26.6 ───── 40
接著遍歷整條 Polyline,把每一組相鄰 Route Point 都用相同方式切成短 Segment,並同時計算每一段的累積距離。
GPS 不一定剛好每 15 公尺更新一次,假設前一次匹配到 Segment 10,下一次已經到了 Segment 14:
Segment 10
↓
Segment 11
↓
Segment 12
↓
Segment 13
↓
Segment 14
如果只記錄兩次實際匹配到的 Segment,就會漏掉中間的 11、12、13。
因此只要前後兩次匹配沒有出現異常跳躍,就把中間經過的路段一起標記為完成:
const MAX_COVERAGE_SEGMENT_JUMP = 10;
const indexDifference =
Math.abs(
currentIndex - previousIndex,
);
if (
indexDifference <=
MAX_COVERAGE_SEGMENT_JUMP
) {
const start = Math.min(
previousIndex,
currentIndex,
);
const end = Math.max(
previousIndex,
currentIndex,
);
for (
let index = start;
index <= end;
index += 1
) {
coveredSegments.add(index);
}
}
這裡不能無條件補滿兩次匹配之間的路段。
例如 GPS 在 U 型路線上突然從 Segment 20 誤匹配到 Segment 80,如果直接把中間全部算成完成,完成率就會瞬間暴增。
因此只有 index 差距在合理範圍內時,才補上中間路段。
另外只要使用者進入 off_route,就會中斷前後兩次匹配的連續關係。重新回到路線後會重新開始記錄,避免把偏離期間沒有走過的路段一起算進去。
最後把 coveredSegments 裡已完成的路段長度加總:
const completedDistanceMeters =
[...coveredSegments].reduce(
(total, index) =>
total +
segments[index].lengthMeters,
0,
);
再除以整條路線的 Segment 總長度:
const completionRatio = completedDistanceMeters / totalRouteDistanceMeters;
假設整條路線長 2000 公尺,目前實際完成 1300 公尺:
completionRatio = 1300 ÷ 2000 = 0.65
顯示時再轉成百分比,最後就是 65%
這樣計算的就不是「總共走了多少距離」,而是「推薦路線中實際走過了多少」。
推薦散步開始後,每次收到 GPS 都會先匹配到推薦路線,再判斷是否偏離,最後記錄真正走過的 Segment:
GPS
↓
匹配 Segment
↓
判斷是否偏離
↓
記錄完成 Segment
↓
計算完成率
這樣即使使用者從路線中段加入、在同一路段來回走、中途偏離,或 GPS 採樣時漏掉部分位置,完成率也不會單純隨著散步距離增加。
到這裡,推薦路線除了可以產生與顯示,也能在散步過程中持續追蹤目前位置、偏離狀態與實際完成比例。