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


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 分鐘向 TDX 請求一次雪隧入口處與隧道內的 VD 數據。
- 數據清洗: 過濾掉異常值(例如車速 0 可能是設備故障,而非真的不動)。
- 計算權重: * 即時車速 (V): 直接比較內外側車道目前平均時速。
佔有率 (O): 車輛佔據偵測器的時間比例,越高代表越擁塞。
B. 判斷邏輯 (The "Smart" Algorithm)
程式不應只看當下的速度,而應參考「趨勢」。

3. 網頁前端介面 (UI/UX)
為了讓駕駛在進入隧道前「一眼看懂」,介面應極簡化:
- 儀表板風格: 使用紅、黃、綠三色代表擁塞程度。
- 視覺化圖表:
- 雙向分流顯示:

<!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(偵測器編號)中。

3. 智慧權重:決策計分模型
這是程式的「大腦」。我們不能只看速度,因為時速 80 可能只是前方剛好沒車的假象。
程式使用了以下加權公式來計算 車道順暢得分 (Score):

4. 雙點對比:隧道深處預警
為了防止「進去才發現大塞車」,程式採用了 「雙點監測法」:
- 入口點 (Entry Point): 判斷你現在該切換到哪一條車道。
- 深處點 (Deep Point, 17.6K): 揭露隧道內部的真實狀況。
如果入口很順(綠燈),但深處點速度極慢(紅燈),介面會顯示「隧道深處壅塞」,提示駕駛即便現在入口很順,也不要掉以輕心。
5. UI/UX 邏輯
- 極簡化設計: 採用高對比度的深色模式,符合駕駛環境的需求。
- 視覺引導: 當系統決定走內側時,內側卡片會發光(Lane Highlight),駕駛只需餘光一瞥即可做出判斷,無需閱讀文字。


