Published at

串接交通部API,用Next.js做一個等公車網頁-Part.1 取得資料

現有的等公車程式都有些缺憾,於是決定自己做一個出來。先串好API再說。

Authors
  • avatar
    Name
    Zup
    Twitter
分享
Table of Contents

公車是全台灣最密集的大眾運輸工具,只不過我每次要搭公車時,都在想要做哪一班車、怎麼轉程才會到目的地。雖然Google Map可以幫你規劃,但我希望達成如捷運路線圖,一眼看過去就能知道要搭哪班車的境界。

不過全部顯示會讓人眼花撩亂,像之前去台南時拍的公車路線圖,反而沒辦法一眼看出轉乘的順序。如果用手動將路線加上去,想必會有更好的效果。

台南車站附近公車路網圖

用Next.js寫一個等公車的網站,預計的功能要有:

  1. 可以選擇公車,顯示行經站牌的預估到站時間外,並於地圖上劃出路線
  2. 可依據站牌選擇經過的路線,並顯示各路線到站預估時間
  3. 能夠將公車路線長駐在地圖上,根據使用者的需求形成路網圖
  4. 在不同縣市都能運作

先來解決取得資料的問題,之後再來煩惱UI怎麼寫。

規劃

我希望地圖站至少一半以上,這樣子才能凸顯路線圖的功能。另外為了滿足上面的要求,我想把他們做成不同的「面板」,並能夠從控制區切換顯示。

  • 公車面板

選擇一個公車路線,顯示所有經過的站牌與到站預估時間(定期更新),也要能切換不同的方向。點擊站牌後地圖會移動到該位置,並顯示詳細資訊。另外也要可以直接從公車面板切換到站牌面板,例如台北基隆路幹線有經過捷運公館站,則應該要可以從「顯示基隆路幹線的公車面板」直接切換到「顯示捷運公館站的站牌面板」。

另外,可以選擇要不要把該路線「疊加」到地圖,就算切換選取的公車,疊加的路線仍然長駐在地圖上。

  • 站牌面板

選擇並顯示該站牌有停靠的所有公車,無論順向還是逆向,並顯示他們預估到站時間(也要定期更新)。與公車面板類似,要能夠從「顯示捷運公館站的站牌面板」切換到「顯示基隆路幹線的公車面板」。

此外,也可以選擇要不要將路線疊加至地圖。

  • 疊加面板

這裡會顯示所有長駐「疊加」的路線,可以選擇刪除,或是切換到顯示該路線的公車面板。


至於地圖,主要當然是顯示路線與站牌位置。其他關於路線、站牌…blablabla的操作都會在面板中完成,地圖只負責顯示而已。

create-t3-app

因為會用到tRPC,與其自己設定半天,不如直接用現成的模板,省去很多麻煩。另外也使用TailwindCSS作為style solution,不為什麼,單純這樣子會比較有效率。

當然用原生的create-next-app也行,只是要自己設定就是了。另外因為會使用到一些新功能,所以會使用App Router來進行,記得不要選錯了。

雖然基本上已經成為標配了,不過整個專案都會使用Typescript,可以有效減少因型別問題而產生的錯誤。

Data Flow & State Management

TDX & env

這裡全部的資料都是透過交通部TDX所提供,要先申請一個帳號,取得client_idclient_secret後才能開始用API(不用其實也可以,但就會有流量限制)。

把id和secret小心翼翼地收到.env後,每次都用process.env.client_id說實在不太安全,沒有型別提示很容易會不小心打錯,又要花一堆時間debug。

因此我們來新增一個env.ts,使用zod來減輕打錯字整組壞掉的焦慮(t3-app有可以直接修改的範本,不過還是重新帶一次)

安裝zod

npm i zod

設定env.ts

import { z } from "zod";

const EnvSchema = z.object({
  client_id: z.string(),
  client_secret: z.string(),
});

export const env = EnvSchema.parse({
  client_id: process.env.client_id,
  client_secret: process.env.client_secret,
});

如果之後後端要使用,就從這個檔案匯入env就好了。

不過id和secret並不能直接使用,要先經過認證、回傳access_token才行。把這個過程獨立成一個檔案get_access_token.ts

import { env } from "~/env";

export async function get_access_token() {
  const res = await fetch(
    "https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token",
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "client_credentials",
        client_id: env.client_id,
        client_secret: env.client_secret,
      }),
    }
  );

  return (await res.json()) as {
    access_token: string;
  };
}

接下來就可以拿著這個access_token來去呼叫TDX API了。

Fetch Data

根據需求,我們會需要API做幾件事情

會用到的OData語法

這些語法是透過URL SearchParams來傳遞的。例如我只要取得公車的路線名稱

fetch(
  `https://tdx.transportdata.tw/api/basic/v2/Bus/Route/City/Taipei?&$select=RouteName,SubRoutes&$format=JSON`
);

/City/Taipei的後面加了?&$select=RouteName&$format=JSON。?代表後面開始是SearchParams。&代表連接不同的變數,放在最前面沒有特別意義。而$select、$format則是變數名稱。

所以$select=RouteName,SubRoutes代表選擇RouteName與SubRoutes這兩個欄位。而$format=JSON代表回傳的資料格式為JSON。

若要過濾,則要使用$filter來輸入邏輯。如果要指定找尋中文名稱為’羅斯福路幹線’公車的話,要在後面加上$filter=RouteName/Zh_tw eq '羅斯福路幹線',這樣子TDX就會回傳過濾後的資料了。

另外過濾也有不同的用法,例如要找到「名稱包含幹線」的公車,則語法為$filter=contains(RouteName/Zh_tw,'幹線'),TDX API就會回傳正確的資料。

TDX Gitbook也有使用說明。

取得所有公車資料

因為不同縣市的路線都不同,從TDX拿取資料最方便。另外我也不希望API的使用次數、流量爆炸,所以限制存取一些欄位就好,剩下的就不用管了。

最基礎的寫法如下

const access_token_res = await get_access_token();
const access_token = access_token_res.access_token;
const res = fetch(
  `https://tdx.transportdata.tw/api/basic/v2/Bus/Route/City/Taipei?&$select=SubRoutes,RouteName`,
  {
    headers: {
      Authorization: `Bearer ${access_token}`,
    },
  }
);
const data = (await res.json()) as BusList[];

因為從網路上抓下來的資料並不包含型別,所以這裡要手動指派。方法也很簡單,先用Postman之類的軟體抓一次資料,再去quicktype.io這類型的網站自動生成型別,之後手動指派給抓下來的資料就好了。

當然,不指派型別也行,之後要使用時就不會有提示,出錯機率很高。時間應該要花在有意義的地方,花點幾分鐘設定型別資料,可以省下之後debug的時間。


你有發現一件事嗎,就是上面的code其實只有在後端(或是React Server Component)才能運行,因為環境變數沒有前綴NEXT_PUBLIC,執行get_access_token()時會爆出錯誤。

當然這也不是說你要把client_idclient_secret都暴露到前端,而是我們要繞一下路,利用Server Action,讓前端碰不到變數的同時也能取得資料。詳細的原理可以參考這篇文章

原則上把邏輯都寫進Server Action函數裡面,就不會有洩漏的問題。因此前面取得所有公車資料的寫法變成:

getAllBus.ts

"use server";

import { get_access_token } from "./get_access_token";
import { type BusList } from "./../type/bus";

export async function getAllBus(city?: string) {
  const access_token_res = await get_access_token();
  const access_token = access_token_res.access_token;
  const initBusList_res = await fetch(
    `https://tdx.transportdata.tw/api/basic/v2/Bus/Route/City/${
      city ?? "Taichung"
    }?&$select=SubRoutes,RouteName`,
    {
      headers: {
        Authorization: `Bearer ${access_token}`,
      },
      next: { revalidate: 43200 }, // 顯示所有路線的資料不需要常常更新,所以間隔可以設長一點
    }
  ).then(res => res);
  const initBusList = (await initBusList_res.json()) as BusList[];
  return initBusList;
}

取得路線站牌資料

getBusStops.ts

"use server";

import { get_access_token } from "~/server/api/routers/bus";
import { type BusStops } from "~/type/bus";

export async function getBusStops(bus: string, city: string) {
  const access_token_res = await get_access_token();
  const access_token = access_token_res.access_token;
  const res = await fetch(
    `https://tdx.transportdata.tw/api/basic/v2/Bus/StopOfRoute/City/${city}/${bus}?$select=RouteName,Direction,Stops&$filter=RouteName/Zh_tw eq '${bus}'&$format=JSON`,
    {
      headers: {
        Authorization: `Bearer ${access_token}`,
      },
      next: { revalidate: 43200 },
    }
  ).then(res => {
    return res;
  });
  const data = (await res.json()) as BusStops[];
  return data;
}

取得公車路線圖資

getBusShape.ts

"use server";

import { get_access_token } from "~/server/api/routers/bus";
import { type BusGeo } from "~/type/bus";

export async function getBusShape(bus: string, city: string) {
  const access_token_res = await get_access_token();
  const access_token = access_token_res.access_token;
  const res = await fetch(
    `https://tdx.transportdata.tw/api/basic/v2/Bus/Shape/City/${city}/${bus}?$select=Direction,RouteName,Geometry&$filter=RouteName/Zh_tw eq '${bus}'&$format=JSON`,
    {
      headers: {
        Authorization: `Bearer ${access_token}`,
      },
      next: { revalidate: 43200 },
    }
  ).then(res => {
    return res;
  });
  const data = (await res.json()) as BusGeo[];
  return data;
}

搜尋站牌

searchStop.ts

"use server";

import { get_access_token } from "~/server/api/routers/bus";
import { type BusStopSearchResult } from "~/type/bus";

export async function searchStop(q: string, city: string) {
  const access_token_res = await get_access_token();
  const access_token = access_token_res.access_token;
  const res = await fetch(
    `https://tdx.transportdata.tw/api/basic/v2/Bus/Stop/City/${city}?%24top=30&%24format=JSON&$filter=contains(StopName/Zh_tw,'${q}')&$select=StopName`,
    {
      headers: {
        Authorization: `Bearer ${access_token}`,
      },
      next: { revalidate: 43200 },
    }
  ).then(res => {
    return res;
  });
  const data = (await res.json()) as BusStopSearchResult[];
  return data;
}

取得預估時間

這時tRPC就要出場了,作為連接前後端的橋樑,它很適合做「取得預估時間」這種需要常常更新的資料。

如果你沒有使用create-t3-app,那可能要花一點時間去把所有的檔案設定好,如果你還不熟悉這個工具的話,過程可能會有些痛苦。照著官網設定就好了。

一樣先來看需求:

  • 一個公車路線行經站牌的預估時間
  • 一個站牌,顯示不同路線到站預估時間

所以會有兩種搜尋方式。為求方便寫成兩個不同的方法:

import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; //看你怎麼設定,import路徑可能會不同
import type { BusEst, BusRoutePassBy } from "~/type/bus"; //匯入自定義的type

export const busRouter = createTRPCRouter({
  getBusEst: publicProcedure
    .input(
      z.object({
        bus: z.string(),
        city: z.string(),
      })
    )
    .query(async ({ input }) => {
      const access_token_res = await get_access_token();
      const access_token = access_token_res.access_token;
      const res = await fetch(
        `https://tdx.transportdata.tw/api/basic/v2/Bus/EstimatedTimeOfArrival/City/${input.city}/${input.bus}?$select=RouteName,StopName,Direction,NextBusTime,StopStatus,StopSequence,EstimateTime&$filter=RouteName/Zh_tw eq '${input.bus}'&$format=JSON`,
        {
          headers: {
            Authorization: `Bearer ${access_token}`,
          },
        }
      ).then(res => {
        return res;
      });
      const data = (await res.json()) as BusEst[];
      return data;
    }),
  getRoutePassBy: publicProcedure
    .input(
      z.object({
        stopName: z.string(),
        city: z.string(),
      })
    )
    .query(async ({ input }) => {
      const access_token_res = await get_access_token();
      const access_token = access_token_res.access_token;
      const res = await fetch(
        `https://tdx.transportdata.tw/api/basic/v2/Bus/EstimatedTimeOfArrival/City/${input.city}?$filter=StopName/Zh_tw eq '${input.stopName}'&$select=Direction,RouteName,NextBusTime,EstimateTime&$format=JSON`,
        {
          headers: {
            Authorization: `Bearer ${access_token}`,
          },
        }
      ).then(res => {
        return res;
      });
      const data = (await res.json()) as BusRoutePassBy[];
      return data;
    }),
});

tRPC的細節就不多說了,總之現在後端都設定完成了,包含get_access_token.tsgetAllBus.tsgetBusStops.tsgetBusShape.tssearchStop.ts五個Server Action,還有在tRPC Router定義的getBusEstgetRoutePassBy兩個函式。後端都完成了,接下來可以專注在前端了。

Jotai

慢著,即使現在有了資料傳輸的方法,但前端要把他們存起來,才可以好好利用。更不用說有些資料是需要共享的,例如站牌資料不只公車面板需要,地圖也要存取各站點的經緯度以放置圖標。

useState雖然可以,但太多層的資料傳遞會極度麻煩。而Context API雖然也能解決這個問題,但<Provider>下面所有的元件都會一起rerender,效能上不太行。因此使用一個外部的解決方案就變得必要了。

Jotai解決了上述的問題。

要開始使用,我的習慣是到獨立的資料夾新增atom,之後在不同元件中,只要用useAtom hook就可以正常瀏覽資料,而不用擔心rerender與provider上下層級的關係。

新增一個atom

import { atom } from "jotai";
import type { BusStops } from "~/type/bus";
export const busStopsAtom = atom<BusStops[] | null>(null);

存取atom

"use client";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { busStopsAtom } from "~/state/bus.ts"

export default function Page() {
  const [data, setData] = useAtom(busStopsAtom) // 功能和useState一模一樣

  const readOnly = useAtomValue(busStopsAtom)
  const setOnly = useSetAtom(busStopsAtom)

  return (
    ...
  )
}

小節

到這裡發現筆電快沒電了,而且也已經寫很多了。現在主要的架構、最重要的資料流都處理好了,接下來剩下

  • 地圖與各種圖標、路線
  • 卡片UI與功能

這系列不知道會分成幾篇,To be continued…

分享