XiaoBai1221's picture
Done
6c78660
const positions = ['pos-top-right', 'pos-top-left', 'pos-bottom-right', 'pos-bottom-left'];
let usedPositions = [];
const MAX_CARDS = 4;
const LABELS = {
zh: {
temperature: '溫度', condition: '狀況', humidity: '濕度', wind_speed: '風速',
weather: '天氣', city: '城市', description: '描述',
feels_like: '體感', pressure: '氣壓', sunrise: '日出', sunset: '日落',
heart_rate: '心率', step_count: '步數', oxygen_level: '血氧', respiratory_rate: '呼吸',
sleep_analysis: '睡眠', record_time: '記錄時間', average: '平均值',
no_news: '無新聞', no_data: '無數據', unknown: '未知',
exchange_rate: '匯率', conversion: '轉換', time: '時間',
train_type: '車種', origin_station: '起站', dest_station: '迄站',
departure: '出發', arrival: '抵達', duration: '行駛時間',
distance: '距離', walking_time: '步行時間', station: '車站',
available_bikes: '可借車輛', available_spaces: '可還空位',
bike_type: '類型', service_status: '服務狀態', operating: '營運中', suspended: '暫停服務',
location: '位置', coordinates: '座標', origin: '起點', destination: '目的地',
estimated_time: '預估時間', view_in_maps: '在 Google Maps 中查看',
line: '路線', address: '地址', road: '道路', area: '區域'
},
en: {
temperature: 'Temperature', condition: 'Condition', humidity: 'Humidity', wind_speed: 'Wind Speed',
weather: 'Weather', city: 'City', description: 'Description',
feels_like: 'Feels Like', pressure: 'Pressure', sunrise: 'Sunrise', sunset: 'Sunset',
heart_rate: 'Heart Rate', step_count: 'Steps', oxygen_level: 'Oxygen', respiratory_rate: 'Respiratory',
sleep_analysis: 'Sleep', record_time: 'Record Time', average: 'Average',
no_news: 'No News', no_data: 'No Data', unknown: 'Unknown',
exchange_rate: 'Exchange Rate', conversion: 'Conversion', time: 'Time',
train_type: 'Train Type', origin_station: 'Origin', dest_station: 'Destination',
departure: 'Departure', arrival: 'Arrival', duration: 'Duration',
distance: 'Distance', walking_time: 'Walking Time', station: 'Station',
available_bikes: 'Available Bikes', available_spaces: 'Available Spaces',
bike_type: 'Type', service_status: 'Service Status', operating: 'Operating', suspended: 'Suspended',
location: 'Location', coordinates: 'Coordinates', origin: 'Origin', destination: 'Destination',
estimated_time: 'Estimated Time', view_in_maps: 'View in Google Maps',
line: 'Line', address: 'Address', road: 'Road', area: 'Area'
},
ko: {
temperature: '온도', condition: '상태', humidity: '습도', wind_speed: '풍속',
weather: '날씨', city: '도시', description: '설명',
feels_like: '체감', pressure: '기압', sunrise: '일출', sunset: '일몰',
heart_rate: '심박수', step_count: '걸음 수', oxygen_level: '혈중 산소', respiratory_rate: '호흡',
sleep_analysis: '수면', record_time: '기록 시간', average: '평균',
no_news: '뉴스 없음', no_data: '데이터 없음', unknown: '알 수 없음',
exchange_rate: '환율', conversion: '환전', time: '시간',
train_type: '열차 종류', origin_station: '출발역', dest_station: '도착역',
departure: '출발', arrival: '도착', duration: '소요 시간',
distance: '거리', walking_time: '도보 시간', station: '역',
available_bikes: '대여 가능', available_spaces: '반납 가능',
bike_type: '유형', service_status: '서비스 상태', operating: '운영 중', suspended: '일시 중단',
location: '위치', coordinates: '좌표', origin: '출발지', destination: '목적지',
estimated_time: '예상 시간', view_in_maps: 'Google Maps에서 보기',
line: '노선', address: '주소', road: '도로', area: '지역'
},
ja: {
temperature: '気温', condition: '状況', humidity: '湿度', wind_speed: '風速',
weather: '天気', city: '都市', description: '説明',
feels_like: '体感', pressure: '気圧', sunrise: '日の出', sunset: '日の入り',
heart_rate: '心拍数', step_count: '歩数', oxygen_level: '血中酸素', respiratory_rate: '呼吸',
sleep_analysis: '睡眠', record_time: '記録時刻', average: '平均',
no_news: 'ニュースなし', no_data: 'データなし', unknown: '不明',
exchange_rate: '為替レート', conversion: '換算', time: '時刻',
train_type: '列車種別', origin_station: '出発駅', dest_station: '到着駅',
departure: '出発', arrival: '到着', duration: '所要時間',
distance: '距離', walking_time: '徒歩時間', station: '駅',
available_bikes: '利用可能', available_spaces: '返却可能',
bike_type: 'タイプ', service_status: 'サービス状態', operating: '運行中', suspended: '一時停止',
location: '場所', coordinates: '座標', origin: '出発地', destination: '目的地',
estimated_time: '予想時間', view_in_maps: 'Google Mapsで見る',
line: '路線', address: '住所', road: '道路', area: 'エリア'
},
id: {
temperature: 'Suhu', condition: 'Kondisi', humidity: 'Kelembaban', wind_speed: 'Kecepatan Angin',
weather: 'Cuaca', city: 'Kota', description: 'Deskripsi',
feels_like: 'Terasa', pressure: 'Tekanan', sunrise: 'Matahari Terbit', sunset: 'Matahari Terbenam',
heart_rate: 'Detak Jantung', step_count: 'Langkah', oxygen_level: 'Oksigen', respiratory_rate: 'Pernapasan',
sleep_analysis: 'Tidur', record_time: 'Waktu Rekam', average: 'Rata-rata',
no_news: 'Tidak Ada Berita', no_data: 'Tidak Ada Data', unknown: 'Tidak Diketahui',
exchange_rate: 'Nilai Tukar', conversion: 'Konversi', time: 'Waktu',
train_type: 'Jenis Kereta', origin_station: 'Stasiun Asal', dest_station: 'Stasiun Tujuan',
departure: 'Keberangkatan', arrival: 'Kedatangan', duration: 'Durasi',
distance: 'Jarak', walking_time: 'Waktu Jalan', station: 'Stasiun',
available_bikes: 'Sepeda Tersedia', available_spaces: 'Tempat Tersedia',
bike_type: 'Tipe', service_status: 'Status Layanan', operating: 'Beroperasi', suspended: 'Ditangguhkan',
location: 'Lokasi', coordinates: 'Koordinat', origin: 'Asal', destination: 'Tujuan',
estimated_time: 'Waktu Estimasi', view_in_maps: 'Lihat di Google Maps',
line: 'Jalur', address: 'Alamat', road: 'Jalan', area: 'Area'
},
vi: {
temperature: 'Nhiệt độ', condition: 'Tình trạng', humidity: 'Độ ẩm', wind_speed: 'Tốc độ gió',
weather: 'Thời tiết', city: 'Thành phố', description: 'Mô tả',
feels_like: 'Cảm giác', pressure: 'Áp suất', sunrise: 'Mặt trời mọc', sunset: 'Mặt trời lặn',
heart_rate: 'Nhịp tim', step_count: 'Số bước', oxygen_level: 'Oxy', respiratory_rate: 'Hô hấp',
sleep_analysis: 'Giấc ngủ', record_time: 'Thời gian ghi', average: 'Trung bình',
no_news: 'Không có tin', no_data: 'Không có dữ liệu', unknown: 'Không rõ',
exchange_rate: 'Tỷ giá', conversion: 'Chuyển đổi', time: 'Thời gian',
train_type: 'Loại tàu', origin_station: 'Ga đi', dest_station: 'Ga đến',
departure: 'Khởi hành', arrival: 'Đến', duration: 'Thời gian di chuyển',
distance: 'Khoảng cách', walking_time: 'Thời gian đi bộ', station: 'Ga',
available_bikes: 'Xe có sẵn', available_spaces: 'Chỗ trống',
bike_type: 'Loại', service_status: 'Trạng thái dịch vụ', operating: 'Hoạt động', suspended: 'Tạm ngừng',
location: 'Vị trí', coordinates: 'Tọa độ', origin: 'Điểm đi', destination: 'Điểm đến',
estimated_time: 'Thời gian ước tính', view_in_maps: 'Xem trên Google Maps',
line: 'Tuyến', address: 'Địa chỉ', road: 'Đường', area: 'Khu vực'
}
};
let currentLanguage = 'zh';
let toolDrawer = null;
let toolDrawerToggle = null;
let toolDrawerContent = null;
let toolDrawerOverlay = null;
let toolDrawerClose = null;
let isDrawerOpen = false;
function initToolDrawer() {
toolDrawer = document.getElementById('toolDrawer');
toolDrawerToggle = document.getElementById('toolDrawerToggle');
toolDrawerContent = document.getElementById('toolDrawerContent');
toolDrawerOverlay = document.getElementById('toolDrawerOverlay');
toolDrawerClose = document.getElementById('toolDrawerClose');
if (!toolDrawer || !toolDrawerToggle) {
console.warn('⚠️ 工具抽屜元素未找到');
return;
}
toolDrawerToggle.addEventListener('click', toggleToolDrawer);
if (toolDrawerClose) {
toolDrawerClose.addEventListener('click', hideToolDrawer);
}
if (toolDrawerOverlay) {
toolDrawerOverlay.addEventListener('click', hideToolDrawer);
}
}
function showToolDrawerToggle() {
if (toolDrawerToggle) {
toolDrawerToggle.classList.add('visible');
}
}
function hideToolDrawerToggle() {
if (toolDrawerToggle) {
toolDrawerToggle.classList.remove('visible');
toolDrawerToggle.classList.remove('open');
}
}
function toggleToolDrawer() {
if (isDrawerOpen) {
hideToolDrawer();
} else {
showToolDrawer();
}
}
function showToolDrawer() {
if (toolDrawer) {
toolDrawer.classList.add('open');
toolDrawerToggle?.classList.add('open');
toolDrawerOverlay?.classList.add('visible');
isDrawerOpen = true;
}
}
function hideToolDrawer() {
if (toolDrawer) {
toolDrawer.classList.remove('open');
toolDrawerToggle?.classList.remove('open');
toolDrawerOverlay?.classList.remove('visible');
isDrawerOpen = false;
}
}
function hideToolCards() {
hideToolDrawer();
hideToolDrawerToggle();
if (toolDrawerContent) {
toolDrawerContent.innerHTML = '';
}
clearAllCards();
}
function getNextPosition() {
if (usedPositions.length >= MAX_CARDS) {
console.warn('⚠️ 卡片數量已達上限(4張),請先清除現有卡片');
return null;
}
for (const pos of positions) {
if (!usedPositions.includes(pos)) {
usedPositions.push(pos);
return pos;
}
}
return null;
}
function addToolCard(type) {
const position = getNextPosition();
if (!position) {
return;
}
const card = document.createElement('div');
card.className = `voice-tool-card ${position}`;
card.dataset.type = type;
if (type === 'weather') {
card.innerHTML = `
<div class="card-header">
<div class="card-icon">🌤️</div>
<h3>台北天氣</h3>
</div>
<div class="card-content">
<div class="data-row">
<span class="data-label">溫度</span>
<span class="data-value">23°C</span>
</div>
<div class="data-row">
<span class="data-label">狀況</span>
<span class="data-value">晴朗</span>
</div>
<div class="data-row">
<span class="data-label">濕度</span>
<span class="data-value">65%</span>
</div>
</div>
`;
} else if (type === 'news') {
card.innerHTML = `
<div class="card-header">
<div class="card-icon">📰</div>
<h3>今日科技新聞</h3>
</div>
<div class="card-content">
<div class="data-row">
<span style="font-size: 13px; line-height: 1.6;">
• OpenAI 發布新模型<br>
• 蘋果推出 Vision Pro 2<br>
• 台積電宣布 2nm 製程
</span>
</div>
</div>
`;
} else if (type === 'health') {
card.innerHTML = `
<div class="card-header">
<div class="card-icon">❤️</div>
<h3>健康數據</h3>
</div>
<div class="card-content">
<div class="data-row">
<span class="data-label">心率</span>
<span class="data-value">72 bpm</span>
</div>
<div class="data-row">
<span class="data-label">步數</span>
<span class="data-value">8,542</span>
</div>
<div class="data-row">
<span class="data-label">血氧</span>
<span class="data-value">98%</span>
</div>
</div>
`;
}
cardsContainer.appendChild(card);
}
function clearAllCards() {
const cards = cardsContainer.querySelectorAll('.voice-tool-card');
cards.forEach(card => {
card.classList.add('exiting');
setTimeout(() => card.remove(), 300);
});
usedPositions = [];
}
function initToolCardControls() {
document.getElementById('simulate-weather').addEventListener('click', () => {
clearAllCards();
setTimeout(() => addToolCard('weather'), 100);
});
document.getElementById('simulate-news').addEventListener('click', () => {
clearAllCards();
setTimeout(() => addToolCard('news'), 100);
});
document.getElementById('simulate-health').addEventListener('click', () => {
clearAllCards();
setTimeout(() => addToolCard('health'), 100);
});
document.getElementById('simulate-next-input').addEventListener('click', () => {
clearAllCards();
transcript.textContent = '請說話...';
transcript.className = 'voice-transcript provisional';
});
}
async function syncToolMetadata() {
try {
const response = await fetch('/api/mcp/tools', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('jwt_token')}`
}
});
if (response.ok) {
const data = await response.json();
if (data.success && data.tools) {
toolsMetadata = {};
data.tools.forEach(tool => {
toolsMetadata[tool.name] = tool;
});
}
}
} catch (error) {
console.error('❌ 同步工具 metadata 失敗:', error);
}
}
function getIconForTool(toolName, category) {
const iconMap = {
'健康': '❤️',
'天氣': '🌤️',
'新聞': '📰',
'匯率': '💱',
'時間': '⏰',
'提醒': '⏰',
'日曆': '📅',
'音樂': '🎵',
'地圖': '🗺️',
'翻譯': '🌐',
'計算': '🔢',
'道路運輸': '🚌',
'軌道運輸': '🚇',
'地理定位': '📍',
'healthkit_query': '❤️',
'weather_query': '🌤️',
'news_query': '📰',
'exchange_rate': '💱',
'time_query': '⏰',
'reminder': '⏰',
'calendar': '📅',
'tdx_bus_arrival': '🚌',
'tdx_metro': '🚇',
'reverse_geocode': '📍',
'forward_geocode': '📍',
'directions': '🗺️'
};
if (iconMap[toolName]) {
return iconMap[toolName];
}
if (category && iconMap[category]) {
return iconMap[category];
}
return '🔧';
}
function displayToolCard(toolName, toolData) {
clearAllCards();
const toolMeta = toolsMetadata[toolName] || {};
const category = toolMeta.category || '未知';
const icon = getIconForTool(toolName, category);
const contentHTML = renderCardContent(toolName, toolData);
const card = document.createElement('div');
card.className = 'voice-tool-card';
card.dataset.type = toolName;
card.innerHTML = `
<div class="card-header">
<div class="card-icon">${icon}</div>
<h3>${category}</h3>
</div>
<div class="card-content" style="max-height: 300px; overflow-y: auto; overflow-x: hidden; padding-right: 8px;">${contentHTML}</div>
`;
if (toolDrawerContent) {
toolDrawerContent.innerHTML = '';
toolDrawerContent.appendChild(card.cloneNode(true));
showToolDrawerToggle();
}
const position = getNextPosition();
if (position && cardsContainer) {
card.classList.add(position);
cardsContainer.appendChild(card);
}
}
function renderCardContent(toolName, toolData) {
if (!toolData) {
console.warn('⚠️ toolData 為空');
return '<p class="data-row">無數據</p>';
}
const healthData = toolData.health_data || toolData.raw_data?.health_data;
if (healthData && Array.isArray(healthData)) {
return renderHealthMetrics(healthData);
}
const articlesData = toolData.articles || toolData.raw_data?.articles;
if (articlesData && Array.isArray(articlesData)) {
return renderNewsList(articlesData);
}
const weatherData = toolData.raw_data || toolData;
if (weatherData.main && weatherData.weather) {
return renderWeatherData(weatherData);
}
if (toolData.arrivals && Array.isArray(toolData.arrivals)) {
return renderBusArrivals(toolData.arrivals, toolData.route_name);
}
if (toolData.stops && Array.isArray(toolData.stops)) {
return renderNearbyStops(toolData.stops);
}
const exchangeData = toolData.raw_data || toolData;
if (exchangeData.rate !== undefined && exchangeData.from_currency !== undefined) {
return renderExchangeRate(exchangeData);
}
if (toolData.trains && Array.isArray(toolData.trains)) {
return renderTrainList(toolData.trains);
}
if (toolData.stations && Array.isArray(toolData.stations) &&
(toolName === 'tdx_youbike' || toolData.stations[0]?.available_bikes !== undefined)) {
return renderYouBikeStations(toolData.stations);
}
if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_train') {
return renderTrainStations(toolData.stations);
}
if (toolData.display_name && toolData.lat && toolData.lon && toolName === 'reverse_geocode') {
return renderReverseGeocode(toolData);
}
if ((toolData.distance_m !== undefined || toolData.duration_s !== undefined) &&
(toolName === 'directions' || toolData.polyline !== undefined)) {
return renderDirections(toolData);
}
if (toolData.arrivals && Array.isArray(toolData.arrivals) && toolName === 'tdx_metro') {
return renderMetroArrivals(toolData.arrivals);
}
if (toolData.stations && Array.isArray(toolData.stations) && toolName === 'tdx_metro') {
return renderMetroStations(toolData.stations);
}
if (toolData.lat && toolData.lon && toolData.display_name && toolName === 'forward_geocode') {
return renderForwardGeocode(toolData);
}
if (toolData.raw_data && typeof toolData.raw_data === 'object') {
return renderKeyValuePairs(toolData.raw_data);
}
console.warn('⚠️ 未匹配任何模式,使用 JSON fallback');
return renderJSONFallback(toolData);
}
function renderWeatherData(data) {
const main = data.main || {};
const weather = data.weather?.[0] || {};
const wind = data.wind || {};
const sys = data.sys || {};
const labels = LABELS[currentLanguage] || LABELS.zh;
const formatTime = (timestamp) => {
if (!timestamp) return '--:--';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('zh-TW', { hour: '2-digit', minute: '2-digit' });
};
return `
<div class="data-row">
<span class="data-label">🌡️ ${labels.temperature}</span>
<span class="data-value">${main.temp?.toFixed(1) || '--'}°C</span>
</div>
<div class="data-row">
<span class="data-label">🤔 ${labels.feels_like}</span>
<span class="data-value">${main.feels_like?.toFixed(1) || '--'}°C</span>
</div>
<div class="data-row">
<span class="data-label">☁️ ${labels.condition}</span>
<span class="data-value">${weather.description || '--'}</span>
</div>
<div class="data-row">
<span class="data-label">💧 ${labels.humidity}</span>
<span class="data-value">${main.humidity || '--'}%</span>
</div>
<div class="data-row">
<span class="data-label">🌪️ ${labels.wind_speed}</span>
<span class="data-value">${wind.speed?.toFixed(1) || '--'} m/s</span>
</div>
<div class="data-row">
<span class="data-label">📊 ${labels.pressure}</span>
<span class="data-value">${main.pressure || '--'} hPa</span>
</div>
<div class="data-row">
<span class="data-label">🌅 ${labels.sunrise}</span>
<span class="data-value">${formatTime(sys.sunrise)}</span>
</div>
<div class="data-row">
<span class="data-label">🌇 ${labels.sunset}</span>
<span class="data-value">${formatTime(sys.sunset)}</span>
</div>
`;
}
function renderHealthMetrics(healthData) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!healthData || healthData.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
const metricIcons = {
heart_rate: '❤️',
step_count: '�',
oxygen_level: '🫁',
respiratory_rate: '💨',
sleep_analysis: '😴'
};
const grouped = {};
healthData.forEach(item => {
const metric = item.metric || item.type;
if (!grouped[metric]) {
grouped[metric] = [];
}
grouped[metric].push(item);
});
let html = '<div class="health-metrics">';
Object.entries(grouped).forEach(([metric, items], index) => {
const icon = metricIcons[metric] || '📊';
const label = labels[metric] || metric;
const latestItem = items[0]; // 最新的數據
const value = latestItem.value;
const unit = latestItem.unit || '';
let timeStr = '';
if (latestItem.timestamp) {
try {
const date = new Date(latestItem.timestamp);
timeStr = date.toLocaleString('zh-TW', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (e) {
timeStr = '';
}
}
html += `
<div class="health-metric-item" style="border-bottom: 1px solid #eee; padding: 10px 0; ${index === Object.keys(grouped).length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row">
<span class="data-label">${icon} ${label}</span>
<span class="data-value" style="font-weight: bold;">${value} ${unit}</span>
</div>
${timeStr ? `
<div class="data-row" style="opacity: 0.7;">
<span class="data-label" style="font-size: 0.85em;">${labels.record_time}</span>
<span class="data-value" style="font-size: 0.85em;">${timeStr}</span>
</div>
` : ''}
${items.length > 1 ? `
<div class="data-row" style="opacity: 0.6;">
<span class="data-label" style="font-size: 0.8em;">${labels.average}</span>
<span class="data-value" style="font-size: 0.8em;">${(items.reduce((sum, i) => sum + i.value, 0) / items.length).toFixed(1)} ${unit}</span>
</div>
` : ''}
</div>
`;
});
html += '</div>';
return html;
}
function renderNewsList(articles) {
const labels = LABELS[currentLanguage] || LABELS.zh;
let html = '';
articles.slice(0, 3).forEach(article => {
html += `
<div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 10px;">
<span class="data-label" style="font-weight: bold;">${article.title || labels.unknown}</span>
<span class="data-value" style="font-size: 0.85em; opacity: 0.8;">${article.source?.name || article.source || ''}</span>
</div>
`;
});
return html || `<p>${labels.no_news}</p>`;
}
function renderKeyValuePairs(data) {
const labels = LABELS[currentLanguage] || LABELS.zh;
const keyMap = {
city: labels.city,
temp: labels.temperature,
temperature: labels.temperature,
condition: labels.condition,
weather: labels.weather,
humidity: labels.humidity,
wind_speed: labels.wind_speed,
description: labels.description
};
let html = '';
for (const [key, value] of Object.entries(data)) {
if (typeof value === 'object') continue; // 跳過巢狀物件
const label = keyMap[key] || key;
let displayValue = value;
if (key.includes('temp') && typeof value === 'number') {
displayValue = `${value}°C`;
}
html += `
<div class="data-row">
<span class="data-label">${label}</span>
<span class="data-value">${displayValue}</span>
</div>
`;
}
return html || '<p>無數據</p>';
}
function renderExchangeRate(data) {
const labels = LABELS[currentLanguage] || LABELS.zh;
const currencySymbols = {
"USD": "$", "TWD": "NT$", "JPY": "¥", "EUR": "€",
"GBP": "£", "CNY": "¥", "KRW": "₩", "HKD": "HK$"
};
const fromCurrency = data.from_currency || "USD";
const toCurrency = data.to_currency || "TWD";
const fromSymbol = currencySymbols[fromCurrency] || fromCurrency;
const toSymbol = currencySymbols[toCurrency] || toCurrency;
let html = '';
if (data.rate !== undefined) {
html += `
<div class="data-row">
<span class="data-label">💰 ${labels.exchange_rate}</span>
<span class="data-value">1 ${fromCurrency} = ${data.rate.toFixed(4)} ${toCurrency}</span>
</div>
`;
}
if (data.amount && data.converted_amount !== undefined) {
html += `
<div class="data-row">
<span class="data-label">🔄 ${labels.conversion}</span>
<span class="data-value">${fromSymbol}${data.amount.toFixed(2)} = ${toSymbol}${data.converted_amount.toFixed(2)}</span>
</div>
`;
}
if (data.raw_data?.metadata?.timestamp) {
const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW');
html += `
<div class="data-row">
<span class="data-label">⏰ ${labels.time}</span>
<span class="data-value">${time}</span>
</div>
`;
}
return html || `<p>${labels.no_data}</p>`;
}
function renderTrainList(trains) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!trains || trains.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
let html = '<div class="train-list">';
trains.forEach((train, index) => {
const trainType = train.train_type || labels.unknown;
const trainNo = train.train_no || '---';
const departTime = train.departure_time ? train.departure_time.substring(0, 5) : '--:--';
const arriveTime = train.arrival_time ? train.arrival_time.substring(0, 5) : '--:--';
const durationText = train.duration_min ? `${train.duration_min}${currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút'}` : labels.unknown;
const originStation = train.origin_station || labels.unknown;
const destStation = train.destination_station || labels.unknown;
html += `
<div class="train-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === trains.length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row" style="margin-bottom: 8px;">
<span class="data-label" style="font-weight: bold; color: #0066cc;">🚂 ${trainType} ${trainNo}</span>
</div>
<div class="data-row">
<span class="data-label">📍 ${labels.origin_station}${labels.dest_station}</span>
<span class="data-value">${originStation}${destStation}</span>
</div>
<div class="data-row">
<span class="data-label">⏰ ${labels.departure}</span>
<span class="data-value">${departTime}</span>
</div>
<div class="data-row">
<span class="data-label">⏱️ ${labels.arrival}</span>
<span class="data-value">${arriveTime}</span>
</div>
<div class="data-row">
<span class="data-label">🕐 ${labels.duration}</span>
<span class="data-value">${durationText}</span>
</div>
</div>
`;
});
html += '</div>';
return html;
}
function renderTrainStations(stations) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!stations || stations.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
let html = '<div class="station-list">';
stations.forEach((station, index) => {
const stationName = station.station_name || station.name || labels.unknown;
const distanceUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? 'm' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? 'm' : 'm';
const distance = station.distance_m ? `${Math.round(station.distance_m)}${distanceUnit}` : '';
const walkTimeText = station.walking_time_min ? `${currentLanguage === 'zh' ? '步行約' : ''}${station.walking_time_min}${currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
html += `
<div class="station-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row" style="margin-bottom: 4px;">
<span class="data-label" style="font-weight: bold; color: #0066cc;">🚉 ${stationName}</span>
</div>
${distance ? `
<div class="data-row">
<span class="data-label">📏 ${labels.distance}</span>
<span class="data-value">${distance}</span>
</div>
` : ''}
${walkTimeText ? `
<div class="data-row">
<span class="data-label">🚶 ${labels.walking_time}</span>
<span class="data-value">${walkTimeText}</span>
</div>
` : ''}
</div>
`;
});
html += '</div>';
return html;
}
function renderYouBikeStations(stations) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!stations || stations.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
let html = '<div class="youbike-list">';
stations.forEach((station, index) => {
const stationName = station.station_name || labels.unknown;
const availableBikes = station.available_bikes ?? 0;
const availableSpaces = station.available_spaces ?? 0;
const distance = station.distance_m || 0;
const walkingTime = station.walking_time_min || 0;
const bikeType = station.bike_type || 'YouBike';
const serviceStatus = station.service_status === 1 ? labels.operating : labels.suspended;
const walkText = currentLanguage === 'zh' ? `步行約 ${walkingTime} 分鐘` : currentLanguage === 'en' ? `${walkingTime} min walk` : currentLanguage === 'ko' ? `도보 ${walkingTime}분` : currentLanguage === 'ja' ? `徒歩${walkingTime}分` : currentLanguage === 'id' ? `${walkingTime} menit jalan` : `${walkingTime} phút đi bộ`;
const bikeUnit = currentLanguage === 'zh' ? '輛' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '대' : currentLanguage === 'ja' ? '台' : currentLanguage === 'id' ? '' : '';
const spaceUnit = currentLanguage === 'zh' ? '個' : currentLanguage === 'en' ? '' : currentLanguage === 'ko' ? '개' : currentLanguage === 'ja' ? '個' : currentLanguage === 'id' ? '' : '';
let bikeStatusColor = '#e74c3c';
let bikeStatusIcon = '🚫';
if (availableBikes > 3) {
bikeStatusColor = '#27ae60';
bikeStatusIcon = '✅';
} else if (availableBikes > 0) {
bikeStatusColor = '#f39c12';
bikeStatusIcon = '⚠️';
}
html += `
<div class="youbike-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row" style="margin-bottom: 8px;">
<span class="data-label" style="font-weight: bold; color: #e67e22;">🚲 ${stationName}</span>
</div>
<div class="data-row">
<span class="data-label">📍 ${labels.distance}</span>
<span class="data-value">${distance}m (${walkText})</span>
</div>
<div class="data-row">
<span class="data-label">🚴 ${labels.available_bikes}</span>
<span class="data-value" style="color: ${bikeStatusColor}; font-weight: bold;">${bikeStatusIcon} ${availableBikes} ${bikeUnit}</span>
</div>
<div class="data-row">
<span class="data-label">🅿️ ${labels.available_spaces}</span>
<span class="data-value">${availableSpaces} ${spaceUnit}</span>
</div>
<div class="data-row">
<span class="data-label">ℹ️ ${labels.bike_type}</span>
<span class="data-value">${bikeType} (${serviceStatus})</span>
</div>
</div>
`;
});
html += '</div>';
return html;
}
function renderBusArrivals(arrivals, routeName) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!arrivals || arrivals.length === 0) {
return `<p>${labels.no_data}</p>`;
}
let html = '';
const stopGroups = {};
arrivals.forEach(arr => {
const stopName = arr.stop_name || labels.unknown;
if (!stopGroups[stopName]) {
stopGroups[stopName] = [];
}
stopGroups[stopName].push(arr);
});
Object.entries(stopGroups).slice(0, 3).forEach(([stopName, stopArrivals], index) => {
const firstArr = stopArrivals[0];
const distance = firstArr.distance_m ? `${Math.round(firstArr.distance_m)}m` : '';
html += `
<div class="data-row" style="flex-direction: column; align-items: flex-start; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(0,0,0,0.05);">
<div style="display: flex; justify-content: space-between; width: 100%; margin-bottom: 4px;">
<span class="data-label" style="font-weight: 600;">🚏 ${stopName}</span>
${distance ? `<span class="data-value" style="font-size: 0.85em; opacity: 0.7;">${distance}</span>` : ''}
</div>
`;
stopArrivals.forEach(arr => {
const directionText = arr.direction === 0 ? (currentLanguage === 'zh' ? '往 ↑' : currentLanguage === 'en' ? 'To ↑' : currentLanguage === 'ko' ? '방향 ↑' : currentLanguage === 'ja' ? '行き ↑' : currentLanguage === 'id' ? 'Ke ↑' : 'Đến ↑') : (currentLanguage === 'zh' ? '返 ↓' : currentLanguage === 'en' ? 'Return ↓' : currentLanguage === 'ko' ? '회차 ↓' : currentLanguage === 'ja' ? '戻り ↓' : currentLanguage === 'id' ? 'Kembali ↓' : 'Về ↓');
const status = arr.status || labels.unknown;
html += `
<div style="display: flex; justify-content: space-between; width: 100%; padding: 2px 0;">
<span style="font-size: 0.9em; opacity: 0.8;">${directionText}</span>
<span class="data-value" style="font-size: 0.9em;">${status}</span>
</div>
`;
});
html += `</div>`;
});
return html;
}
function renderReverseGeocode(data) {
const labels = LABELS[currentLanguage] || LABELS.zh;
const displayName = data.display_name || labels.unknown;
const city = data.city || '';
const road = data.road || '';
const houseNumber = data.house_number || '';
const suburb = data.suburb || '';
const admin = data.admin || '';
const countryCode = data.country_code || '';
const lat = data.lat?.toFixed(6) || '';
const lon = data.lon?.toFixed(6) || '';
let detailedAddress = [];
if (city) detailedAddress.push(city);
if (admin && admin !== city) detailedAddress.push(admin);
if (suburb) detailedAddress.push(suburb);
if (road) detailedAddress.push(road);
if (houseNumber) detailedAddress.push(houseNumber);
const addressText = detailedAddress.length > 0 ? detailedAddress.join(', ') : displayName;
const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
return `
<div class="data-row">
<span class="data-label">📍 ${labels.location}</span>
<span class="data-value" style="font-weight: bold;">${displayName}</span>
</div>
${city ? `
<div class="data-row">
<span class="data-label">🏙️ ${labels.city}</span>
<span class="data-value">${city}</span>
</div>
` : ''}
${road ? `
<div class="data-row">
<span class="data-label">🛣️ ${labels.road}</span>
<span class="data-value">${road}${houseNumber ? ' ' + houseNumber : ''}</span>
</div>
` : ''}
${suburb ? `
<div class="data-row">
<span class="data-label">🏘️ ${labels.area}</span>
<span class="data-value">${suburb}</span>
</div>
` : ''}
<div class="data-row">
<span class="data-label">🌐 ${labels.coordinates}</span>
<span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
</div>
<div class="data-row" style="margin-top: 8px;">
<a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
🗺️ ${labels.view_in_maps}
</a>
</div>
`;
}
function renderNearbyStops(stops) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!stops || stops.length === 0) {
return `<p>${labels.no_data}</p>`;
}
let html = '';
stops.slice(0, 5).forEach((stop, index) => {
const stopName = stop.stop_name || labels.unknown;
const distance = stop.distance_m ? `${Math.round(stop.distance_m)}m` : '';
const walkTimeText = stop.walking_time_min ? `${currentLanguage === 'zh' ? '步行 ' : ''}${stop.walking_time_min}${currentLanguage === 'zh' ? ' 分' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
html += `
<div class="data-row" style="margin-bottom: 8px;">
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 2px;">🚏 ${stopName}</div>
<div style="font-size: 0.85em; opacity: 0.7;">${walkTimeText} ${distance ? `(${distance})` : ''}</div>
</div>
</div>
`;
});
return html;
}
function renderDirections(data) {
const labels = LABELS[currentLanguage] || LABELS.zh;
const originLabel = data.origin_label || labels.origin;
const destLabel = data.dest_label || labels.destination;
const distanceM = data.distance_m;
const durationS = data.duration_s;
let distanceStr = '--';
if (distanceM !== undefined) {
if (distanceM >= 1000) {
const kmUnit = currentLanguage === 'zh' ? '公里' : currentLanguage === 'en' ? ' km' : currentLanguage === 'ko' ? '킬로미터' : currentLanguage === 'ja' ? 'キロ' : currentLanguage === 'id' ? ' km' : ' km';
distanceStr = `${(distanceM / 1000).toFixed(1)}${kmUnit}`;
} else {
const mUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? ' m' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? ' m' : ' m';
distanceStr = `${Math.round(distanceM)}${mUnit}`;
}
}
let durationStr = '--';
if (durationS !== undefined) {
const minutes = Math.round(durationS / 60);
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
const hourUnit = currentLanguage === 'zh' ? '小時' : currentLanguage === 'en' ? ' hr' : currentLanguage === 'ko' ? '시간' : currentLanguage === 'ja' ? '時間' : currentLanguage === 'id' ? ' jam' : ' giờ';
const minUnit = currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút';
durationStr = mins > 0 ? `${hours}${hourUnit} ${mins}${minUnit}` : `${hours}${hourUnit}`;
} else {
const minUnit = currentLanguage === 'zh' ? '分鐘' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút';
durationStr = `${minutes}${minUnit}`;
}
}
let mapsLink = '';
if (data.origin_lat && data.origin_lon && data.dest_lat && data.dest_lon) {
const mapsUrl = `https://www.google.com/maps/dir/${data.origin_lat},${data.origin_lon}/${data.dest_lat},${data.dest_lon}`;
mapsLink = `
<div class="data-row" style="margin-top: 8px;">
<a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
🗺️ ${labels.view_in_maps}
</a>
</div>
`;
}
return `
<div class="data-row">
<span class="data-label">📍 ${labels.origin}</span>
<span class="data-value">${originLabel}</span>
</div>
<div class="data-row">
<span class="data-label">🎯 ${labels.destination}</span>
<span class="data-value">${destLabel}</span>
</div>
<div class="data-row">
<span class="data-label">📏 ${labels.distance}</span>
<span class="data-value">${distanceStr}</span>
</div>
<div class="data-row">
<span class="data-label">⏱️ ${labels.estimated_time}</span>
<span class="data-value">${durationStr}</span>
</div>
${mapsLink}
`;
}
function renderMetroArrivals(arrivals) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!arrivals || arrivals.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
let html = '<div class="metro-arrivals">';
const lineGroups = {};
arrivals.forEach(arr => {
const lineName = arr.line_name || labels.unknown;
if (!lineGroups[lineName]) {
lineGroups[lineName] = [];
}
lineGroups[lineName].push(arr);
});
Object.entries(lineGroups).forEach(([lineName, lineArrivals], index) => {
html += `
<div class="metro-line" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === Object.keys(lineGroups).length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row" style="margin-bottom: 8px;">
<span class="data-label" style="font-weight: bold; color: #0066cc;">🚇 ${lineName}</span>
</div>
`;
lineArrivals.slice(0, 3).forEach(arr => {
const dest = arr.destination || labels.unknown;
const timeSec = arr.arrival_time_sec;
const status = arr.train_status || labels.unknown;
let timeStr = status;
if (timeSec > 0) {
const min = Math.floor(timeSec / 60);
const sec = timeSec % 60;
const minUnit = currentLanguage === 'zh' ? '分' : currentLanguage === 'en' ? ' min' : currentLanguage === 'ko' ? '분' : currentLanguage === 'ja' ? '分' : currentLanguage === 'id' ? ' menit' : ' phút';
const secUnit = currentLanguage === 'zh' ? '秒' : currentLanguage === 'en' ? ' sec' : currentLanguage === 'ko' ? '초' : currentLanguage === 'ja' ? '秒' : currentLanguage === 'id' ? ' detik' : ' giây';
timeStr = min > 0 ? `${min}${minUnit} ${sec}${secUnit}` : `${sec}${secUnit}`;
}
html += `
<div class="data-row">
<span class="data-label">→ ${dest}</span>
<span class="data-value">${timeStr}</span>
</div>
`;
});
html += '</div>';
});
html += '</div>';
return html;
}
function renderMetroStations(stations) {
const labels = LABELS[currentLanguage] || LABELS.zh;
if (!stations || stations.length === 0) {
return `<p class="data-row">${labels.no_data}</p>`;
}
let html = '<div class="metro-stations">';
stations.forEach((station, index) => {
const stationName = station.station_name || labels.unknown;
const distanceUnit = currentLanguage === 'zh' ? '公尺' : currentLanguage === 'en' ? 'm' : currentLanguage === 'ko' ? '미터' : currentLanguage === 'ja' ? 'メートル' : currentLanguage === 'id' ? 'm' : 'm';
const distance = station.distance_m ? `${Math.round(station.distance_m)} ${distanceUnit}` : '';
const walkTimeText = station.walking_time_min ? `${currentLanguage === 'zh' ? '步行約 ' : ''}${station.walking_time_min}${currentLanguage === 'zh' ? ' 分鐘' : currentLanguage === 'en' ? ' min walk' : currentLanguage === 'ko' ? '분 도보' : currentLanguage === 'ja' ? '分 徒歩' : currentLanguage === 'id' ? ' menit jalan' : ' phút đi bộ'}` : '';
const address = station.address || '';
html += `
<div class="metro-station-item" style="border-bottom: 1px solid #eee; padding: 12px 0; ${index === stations.length - 1 ? 'border-bottom: none;' : ''}">
<div class="data-row" style="margin-bottom: 4px;">
<span class="data-label" style="font-weight: bold; color: #0066cc;">🚇 ${stationName}</span>
</div>
${distance ? `
<div class="data-row">
<span class="data-label">📏 ${labels.distance}</span>
<span class="data-value">${distance}</span>
</div>
` : ''}
${walkTimeText ? `
<div class="data-row">
<span class="data-label">🚶 ${labels.walking_time}</span>
<span class="data-value">${walkTimeText}</span>
</div>
` : ''}
${address ? `
<div class="data-row">
<span class="data-label">📍 ${labels.address}</span>
<span class="data-value" style="font-size: 0.85em;">${address}</span>
</div>
` : ''}
</div>
`;
});
html += '</div>';
return html;
}
function renderForwardGeocode(data) {
const labels = LABELS[currentLanguage] || LABELS.zh;
const displayName = data.display_name || labels.unknown;
const lat = data.lat?.toFixed(6) || '';
const lon = data.lon?.toFixed(6) || '';
const city = data.city || '';
const road = data.road || '';
const suburb = data.suburb || '';
const mapsUrl = `https://www.google.com/maps?q=${lat},${lon}`;
return `
<div class="data-row">
<span class="data-label">📍 ${labels.location}</span>
<span class="data-value" style="font-weight: bold;">${displayName}</span>
</div>
${city ? `
<div class="data-row">
<span class="data-label">🏙️ ${labels.city}</span>
<span class="data-value">${city}</span>
</div>
` : ''}
${road ? `
<div class="data-row">
<span class="data-label">🛣️ ${labels.road}</span>
<span class="data-value">${road}</span>
</div>
` : ''}
${suburb ? `
<div class="data-row">
<span class="data-label">🏘️ ${labels.area}</span>
<span class="data-value">${suburb}</span>
</div>
` : ''}
<div class="data-row">
<span class="data-label">🌐 ${labels.coordinates}</span>
<span class="data-value" style="font-size: 0.85em;">${lat}, ${lon}</span>
</div>
<div class="data-row" style="margin-top: 8px;">
<a href="${mapsUrl}" target="_blank" style="color: #0066cc; text-decoration: none; font-size: 0.9em;">
🗺️ ${labels.view_in_maps}
</a>
</div>
`;
}
function renderJSONFallback(data) {
return `<pre style="font-size: 0.85em; white-space: pre-wrap;">${JSON.stringify(data, null, 2)}</pre>`;
}