iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 13

推薦路線(4)——計算路線完成率

  • 分享至 

  • xImage
  •  

前面幾天已經完成推薦路線的產生,也知道使用者什麼時候可以開始散步,但真正開始散步後,還需要處理幾個問題:

  • 使用者目前位於推薦路線的哪個位置?
  • 使用者是否已經偏離路線?
  • 偏離後是否重新回到路線?
  • 推薦路線實際完成了多少?

Google Routes 只會提供規劃好的路線,不會根據後續收到的 GPS 自動計算這些結果,因此這些判斷都需要自己處理。

第一步:將 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;
};

這裡的 xy 不是直接使用經緯度,而是先轉換成以公尺為單位的局部平面座標,後面的向量與距離計算才會使用相同單位。

找出 GPS 在線段上的最近位置

接著利用向量投影,找出 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 可以避免同一段路線被重複計算。

將路線切成短 Segment

原始 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 之間經過的路段

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 採樣時漏掉部分位置,完成率也不會單純隨著散步距離增加。

到這裡,推薦路線除了可以產生與顯示,也能在散步過程中持續追蹤目前位置、偏離狀態與實際完成比例。


上一篇
推薦路線(3)——判斷是否可以開始散步
下一篇
推薦路線(5)——實作路線導航與語音提醒
系列文
30 天開發一款真正能每天使用的散步 App14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言