即時精選

用AI開發個「雪隧車流智慧分析助手」-進隧道前選擇走內側或外側車道

更新 發佈閱讀 32 分鐘

開發一個「雪隧車流智慧分析助手」是一個實用的想法,特別是針對國道五號每逢假日必塞的痛點。要實現這個網頁程式,核心在於數據源的獲取即時邏輯的運算

raw-image
raw-image


利用Gemini AI,它提供以下開發此程式的技術藍圖與實作建議:


1. 核心數據來源:TDX 平台

要取得雪隧內「個別車道」的即時車速,你需要對接交通部推動的 TDX (Transport Data e-Xchange) 平台。

  • API 類型: 使用「國道即時路況 - 偵測器數據 (VD)」。
  • 關鍵參數: * 路段編號: 國道 5 號(國 5)。
  • 里程位置: 雪山隧道大約在 15K 至 28K 之間。
  • LaneID: TDX 的數據通常會區分 Lane 1 (內側車道) 與 Lane 2 (外側車道)。
    需註冊TDX平台 (https://tdx.transportdata.tw/register),取得可連接API的權限。

2. 系統架構設計

A. 資料處理層 (處理json 資料/javascript)

  1. 定期抓取: 每 1 分鐘向 TDX 請求一次雪隧入口處與隧道內的 VD 數據。
  2. 數據清洗: 過濾掉異常值(例如車速 0 可能是設備故障,而非真的不動)。
  3. 計算權重: * 即時車速 (V): 直接比較內外側車道目前平均時速。
    佔有率 (O): 車輛佔據偵測器的時間比例,越高代表越擁塞。

B. 判斷邏輯 (The "Smart" Algorithm)

程式不應只看當下的速度,而應參考「趨勢」。

raw-image

3. 網頁前端介面 (UI/UX)

為了讓駕駛在進入隧道前「一眼看懂」,介面應極簡化:

  • 儀表板風格: 使用紅、黃、綠三色代表擁塞程度。
  • 視覺化圖表:
  • 雙向分流顯示:
raw-image
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>雪隧智慧選道儀表板</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body { background-color: #020617; color: #f8fafc; font-family: sans-serif; }
.lane-highlight { border: 2px solid #3b82f6; background: rgba(59, 130, 246, 0.15); box-shadow: 0 0 20px rgba(59, 130, 246, 0.5); }
.fa-spin-custom { animation: spin 1.5s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
</head>
<body class="p-4 max-w-md mx-auto">

<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-xl font-bold text-blue-400">雪隧智慧儀表板</h1>
<p id="update-tag" class="text-[10px] text-slate-500 uppercase tracking-widest">系統準備中...</p>
</div>
<button onclick="fetchTrafficData()" class="w-10 h-10 bg-slate-900 rounded-full flex items-center justify-center border border-slate-800">
<i id="refresh-icon" class="fas fa-sync-alt text-slate-400"></i>
</button>
</header>

<div class="flex bg-slate-900 rounded-xl p-1 mb-6 border border-slate-800">
<button id="btn-S" onclick="changeDir('S')" class="flex-1 py-2 rounded-lg font-bold transition bg-blue-600">南下 (往宜蘭)</button>
<button id="btn-N" onclick="changeDir('N')" class="flex-1 py-2 rounded-lg font-bold transition text-slate-500">北上 (往台北)</button>
</div>

<div class="bg-slate-900 rounded-3xl p-6 mb-4 border border-slate-800 text-center shadow-2xl relative overflow-hidden">
<span class="text-blue-400 text-xs font-bold uppercase tracking-widest">建議行駛車道</span>
<h2 id="final-choice" class="text-4xl font-black my-2 tracking-tighter italic">-- --</h2>
<div id="entry-vd-tag" class="text-[9px] text-slate-600 font-mono">WAITING FOR SENSOR DATA...</div>
</div>

<div class="bg-slate-900/50 rounded-2xl p-4 mb-6 border-l-4 border-orange-500 flex justify-between items-center shadow-lg">
<div>
<p class="text-[10px] text-slate-500 uppercase font-bold">隧道深處路況 (17.6K)</p>
<p id="deep-status" class="text-sm font-bold text-orange-400 tracking-tight">偵測中...</p>
</div>
<div class="flex gap-4 text-center">
<div>
<p class="text-[9px] text-slate-600">內側</p>
<p id="deep-l0" class="text-lg font-bold">--</p>
</div>
<div>
<p class="text-[9px] text-slate-600">外側</p>
<p id="deep-l1" class="text-lg font-bold">--</p>
</div>
</div>
</div>

<div class="grid grid-cols-2 gap-4">
<div id="card-l0" class="bg-slate-900 rounded-2xl p-5 border border-slate-800 transition-all duration-500">
<p class="text-center text-[10px] text-slate-500 mb-2 font-bold uppercase">內側 (L0)</p>
<div class="text-center">
<span id="l0-speed" class="text-5xl font-black">--</span>
<span class="text-[10px] block text-slate-600 mt-1 uppercase">KM/H</span>
</div>
<div id="l0-occ" class="mt-4 text-[9px] text-center py-1 bg-slate-800/50 rounded-full text-slate-400 font-mono">--% OCC</div>
</div>
<div id="card-l1" class="bg-slate-900 rounded-2xl p-5 border border-slate-800 transition-all duration-500">
<p class="text-center text-[10px] text-slate-500 mb-2 font-bold uppercase">外側 (L1)</p>
<div class="text-center">
<span id="l1-speed" class="text-5xl font-black">--</span>
<span class="text-[10px] block text-slate-600 mt-1 uppercase">KM/H</span>
</div>
<div id="l1-occ" class="mt-4 text-[9px] text-center py-1 bg-slate-800/50 rounded-full text-slate-400 font-mono">--% OCC</div>
</div>
</div>

<script>
// --- 1. 請在此填入您的 TDX 憑證 ---
const CLIENT_ID = 'XXXXX-XXXX-XXXX-XXX';
const CLIENT_SECRET = 'XXXX-XXXX-XXXX-XXXX-XXXX';

let accessToken = "";
let currentDir = 'S';
const CONFIG = {
entranceMile: { S: 15.5, N: 28.5 }, // 入口約略里程
deepMile: 17.608 // 指定監控點
};

// --- 2. 自動獲取 Access Token ---
async function getAccessToken() {
const url = 'https://tdx.transportdata.tw/auth/realms/TDXConnect/protocol/openid-connect/token';
const params = new URLSearchParams({
'grant_type': 'client_credentials',
'client_id': CLIENT_ID,
'client_secret': CLIENT_SECRET
});

try {
const res = await fetch(url, { method: 'POST', body: params });
if (!res.ok) throw new Error("驗證失敗,請檢查 ID/Secret");
const data = await res.json();
accessToken = data.access_token;
return true;
} catch (e) {
document.getElementById('update-tag').innerText = "AUTH ERROR: " + e.message;
return false;
}
}

// --- 3. 抓取交通資料 ---
async function fetchTrafficData() {
const icon = document.getElementById('refresh-icon');
icon.classList.add('fa-spin-custom');

try {
// 如果沒有 Token,先去拿
if (!accessToken) {
const success = await getAccessToken();
if (!success) return;
}

const url = `https://tdx.transportdata.tw/api/basic/v2/Road/Traffic/Live/VD/Freeway?$filter=contains(VDID,'N5')&$format=JSON`;

const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});

// 如果是 401,代表 Token 過期了,清空它下次會重新申請
if (res.status === 401) {
accessToken = "";
return fetchTrafficData();
}

if (!res.ok) throw new Error("API 回傳錯誤: " + res.status);

const data = await res.json();
const vdLives = data.VDLives || [];
processLanes(vdLives);

} catch (e) {
console.error("Fetch Error:", e);
document.getElementById('update-tag').innerText = "錯誤: " + e.message;
} finally {
icon.classList.remove('fa-spin-custom');
}
}

// --- 4. 解析資料結構 (針對 LinkFlows) ---
function processLanes(vds) {
const list = vds.map(vd => {
const mileMatch = vd.VDID.match(/-(\d+\.\d+)-/);
return {
...vd,
extractedMile: mileMatch ? parseFloat(mileMatch[1]) : 0,
// 根據 ID 特徵判斷方向
isCorrectDir: vd.VDID.includes(`-N5-${currentDir}-`)
};
}).filter(vd => vd.isCorrectDir);

// 找最接近入口的偵測器
const entryVD = list.sort((a, b) =>
Math.abs(a.extractedMile - CONFIG.entranceMile[currentDir]) -
Math.abs(b.extractedMile - CONFIG.entranceMile[currentDir])
)[0];

// 找隧道深處的偵測器 (17.6K)
const deepVD = list.sort((a, b) =>
Math.abs(a.extractedMile - CONFIG.deepMile) -
Math.abs(b.extractedMile - CONFIG.deepMile)
)[0];

if (entryVD) updateEntryUI(entryVD);
if (deepVD) updateDeepUI(deepVD);

document.getElementById('update-tag').innerText = "最後更新: " + new Date().toLocaleTimeString();
}

// --- 5. UI 更新邏輯 ---
function updateEntryUI(vd) {
const lanes = vd.LinkFlows[0].Lanes;
const l0 = lanes.find(l => l.LaneID == 0) || { Speed: 0, Occupancy: 0 };
const l1 = lanes.find(l => l.LaneID == 1) || { Speed: 0, Occupancy: 0 };

document.getElementById('l0-speed').innerText = Math.round(l0.Speed);
document.getElementById('l1-speed').innerText = Math.round(l1.Speed);
document.getElementById('l0-occ').innerText = `${Math.round(l0.Occupancy)}% OCC`;
document.getElementById('l1-occ').innerText = `${Math.round(l1.Occupancy)}% OCC`;
document.getElementById('entry-vd-tag').innerText = `SENSOR: ${vd.VDID}`;

// 智慧權重:Score = Speed * (1 - Occupancy/100)
const s0 = l0.Speed * (1 - l0.Occupancy / 100);
const s1 = l1.Speed * (1 - l1.Occupancy / 100) - 3; // 外側固定懲罰 3 分

const choice = s0 >= s1 ? "內側車道" : "外側車道";
const choiceEl = document.getElementById('final-choice');
choiceEl.innerText = choice;
choiceEl.style.color = s0 >= s1 ? "#60a5fa" : "#34d399";

document.getElementById('card-l0').className = `bg-slate-900 rounded-2xl p-5 border border-slate-800 transition-all duration-500 ${s0 >= s1 ? 'lane-highlight' : ''}`;
document.getElementById('card-l1').className = `bg-slate-900 rounded-2xl p-5 border border-slate-800 transition-all duration-500 ${s1 > s0 ? 'lane-highlight' : ''}`;
}

function updateDeepUI(vd) {
const lanes = vd.LinkFlows[0].Lanes;
const l0 = lanes.find(l => l.LaneID == 0) || { Speed: 0 };
const l1 = lanes.find(l => l.LaneID == 1) || { Speed: 0 };

document.getElementById('deep-l0').innerText = Math.round(l0.Speed);
document.getElementById('deep-l1').innerText = Math.round(l1.Speed);

const avg = (l0.Speed + l1.Speed) / 2;
let status = "一路暢通";
let color = "#34d399";

if (avg < 40) { status = "嚴重壅塞"; color = "#f87171"; }
else if (avg < 65) { status = "車多緩慢"; color = "#fbbf24"; }

const el = document.getElementById('deep-status');
el.innerText = status;
el.style.color = color;
}

function changeDir(dir) {
currentDir = dir;
document.getElementById('btn-S').className = `flex-1 py-2 rounded-lg font-bold transition ${dir === 'S' ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-500'}`;
document.getElementById('btn-N').className = `flex-1 py-2 rounded-lg font-bold transition ${dir === 'N' ? 'bg-blue-600 text-white shadow-lg' : 'text-slate-500'}`;
fetchTrafficData();
}

// 初始化
fetchTrafficData();
// 每 60 秒自動刷新
setInterval(fetchTrafficData, 60000);
</script>
</body>
</html>

程式碼說明:

這個網頁程式的原理可以拆解為 「數據獲取」、「幾何定位」 與 「智慧權重決策」 三大核心模組。它模擬了一個自動化交通控制中心的邏輯,將冰冷的數據轉化為駕駛可理解的行車建議。


1. 數據獲取:TDX 雲端對接

程式的第一步是與交通部的 TDX (Transport Data e-Xchange) 平台進行即時連線。

  • OAuth 2.0 認證機制: 為了確保資料安全,程式會先發送 CLIENT_ID 與 CLIENT_SECRET 換取一個臨時通行證(Access Token)。
  • 動態偵測器數據 (VD): 透過 API 抓取國道五號(N5)所有的 車輛偵測器 (Vehicle Detector)。
    這些偵測器是埋在隧道柏油路面下的感應線圈,能即時算出「過車數」、「時速」與「佔有率」。


2. 幾何定位:Regex 里程辨識

這是程式最精密的技術點。TDX 的即時資料庫有時不會直接給出「里程數」欄位,但里程資訊通常被隱藏在 VDID(偵測器編號)中。

raw-image


3. 智慧權重:決策計分模型

這是程式的「大腦」。我們不能只看速度,因為時速 80 可能只是前方剛好沒車的假象。

程式使用了以下加權公式來計算 車道順暢得分 (Score):

raw-image


4. 雙點對比:隧道深處預警

為了防止「進去才發現大塞車」,程式採用了 「雙點監測法」:

  1. 入口點 (Entry Point): 判斷你現在該切換到哪一條車道。
  2. 深處點 (Deep Point, 17.6K): 揭露隧道內部的真實狀況。

如果入口很順(綠燈),但深處點速度極慢(紅燈),介面會顯示「隧道深處壅塞」,提示駕駛即便現在入口很順,也不要掉以輕心。


5. UI/UX 邏輯

  • 極簡化設計: 採用高對比度的深色模式,符合駕駛環境的需求。
  • 視覺引導: 當系統決定走內側時,內側卡片會發光(Lane Highlight),駕駛只需餘光一瞥即可做出判斷,無需閱讀文字。




留言
avatar-img
Hank吳的沙龍
6會員
133內容數
這不僅僅是一個 Blog,更是一個交流與分享的空間。 期待在這裡與你相遇,一起探索科技、體驗生活、夢想旅行!💖
Hank吳的沙龍的其他內容
2026/01/06
委內瑞拉擁有全球證實儲量最大的石油(約 3,000 億桶),甚至超過沙烏地阿拉伯,但其人民卻長期陷入嚴重的貧困與人道危機。這是一個典型的「資源咒詛」(Resource Curse)案例。 要理解這個矛盾,必須從經濟結構、技術困難、政治管理以及國際局勢四個維度來看:
Thumbnail
2026/01/06
委內瑞拉擁有全球證實儲量最大的石油(約 3,000 億桶),甚至超過沙烏地阿拉伯,但其人民卻長期陷入嚴重的貧困與人道危機。這是一個典型的「資源咒詛」(Resource Curse)案例。 要理解這個矛盾,必須從經濟結構、技術困難、政治管理以及國際局勢四個維度來看:
Thumbnail
2025/12/30
台灣近年來的經濟數據確實亮眼,不僅人均 GDP 預計在 2026 年突破 4 萬美元大關,經濟成長率也頻頻優於預期。然而,對於多數一般受薪階級來說,「無感」甚至「實質薪資倒退」的感受卻非常深刻。 這種「數據很豐滿,口袋很骨感」的現象,主要可以歸納為以下幾個核心原因:
Thumbnail
2025/12/30
台灣近年來的經濟數據確實亮眼,不僅人均 GDP 預計在 2026 年突破 4 萬美元大關,經濟成長率也頻頻優於預期。然而,對於多數一般受薪階級來說,「無感」甚至「實質薪資倒退」的感受卻非常深刻。 這種「數據很豐滿,口袋很骨感」的現象,主要可以歸納為以下幾個核心原因:
Thumbnail
2025/12/30
一、 手相的主要構成(看什麼?) 手相的解讀通常分為三大層次:手掌線路、掌丘、以及手型。 1. 五大主線(最常被討論的部分) 生命線(地紋): 從虎口延伸至手腕。它反映的是生命力的強弱、體質和能量,而非單純的壽命長短。 智慧線(人紋): 橫貫手掌中部。
Thumbnail
2025/12/30
一、 手相的主要構成(看什麼?) 手相的解讀通常分為三大層次:手掌線路、掌丘、以及手型。 1. 五大主線(最常被討論的部分) 生命線(地紋): 從虎口延伸至手腕。它反映的是生命力的強弱、體質和能量,而非單純的壽命長短。 智慧線(人紋): 橫貫手掌中部。
Thumbnail
看更多