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 = `
🌤️

台北天氣

溫度 23°C
狀況 晴朗
濕度 65%
`; } else if (type === 'news') { card.innerHTML = `
📰

今日科技新聞

• OpenAI 發布新模型
• 蘋果推出 Vision Pro 2
• 台積電宣布 2nm 製程
`; } else if (type === 'health') { card.innerHTML = `
❤️

健康數據

心率 72 bpm
步數 8,542
血氧 98%
`; } 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 = `
${icon}

${category}

${contentHTML}
`; 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 '

無數據

'; } 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 `
🌡️ ${labels.temperature} ${main.temp?.toFixed(1) || '--'}°C
🤔 ${labels.feels_like} ${main.feels_like?.toFixed(1) || '--'}°C
☁️ ${labels.condition} ${weather.description || '--'}
💧 ${labels.humidity} ${main.humidity || '--'}%
🌪️ ${labels.wind_speed} ${wind.speed?.toFixed(1) || '--'} m/s
📊 ${labels.pressure} ${main.pressure || '--'} hPa
🌅 ${labels.sunrise} ${formatTime(sys.sunrise)}
🌇 ${labels.sunset} ${formatTime(sys.sunset)}
`; } function renderHealthMetrics(healthData) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!healthData || healthData.length === 0) { return `

${labels.no_data}

`; } 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 = '
'; 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 += `
${icon} ${label} ${value} ${unit}
${timeStr ? `
${labels.record_time} ${timeStr}
` : ''} ${items.length > 1 ? `
${labels.average} ${(items.reduce((sum, i) => sum + i.value, 0) / items.length).toFixed(1)} ${unit}
` : ''}
`; }); html += '
'; return html; } function renderNewsList(articles) { const labels = LABELS[currentLanguage] || LABELS.zh; let html = ''; articles.slice(0, 3).forEach(article => { html += `
${article.title || labels.unknown} ${article.source?.name || article.source || ''}
`; }); return html || `

${labels.no_news}

`; } 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 += `
${label} ${displayValue}
`; } return html || '

無數據

'; } 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 += `
💰 ${labels.exchange_rate} 1 ${fromCurrency} = ${data.rate.toFixed(4)} ${toCurrency}
`; } if (data.amount && data.converted_amount !== undefined) { html += `
🔄 ${labels.conversion} ${fromSymbol}${data.amount.toFixed(2)} = ${toSymbol}${data.converted_amount.toFixed(2)}
`; } if (data.raw_data?.metadata?.timestamp) { const time = new Date(data.raw_data.metadata.timestamp).toLocaleString('zh-TW'); html += `
⏰ ${labels.time} ${time}
`; } return html || `

${labels.no_data}

`; } function renderTrainList(trains) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!trains || trains.length === 0) { return `

${labels.no_data}

`; } let html = '
'; 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 += `
🚂 ${trainType} ${trainNo}
📍 ${labels.origin_station} → ${labels.dest_station} ${originStation} → ${destStation}
⏰ ${labels.departure} ${departTime}
⏱️ ${labels.arrival} ${arriveTime}
🕐 ${labels.duration} ${durationText}
`; }); html += '
'; return html; } function renderTrainStations(stations) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!stations || stations.length === 0) { return `

${labels.no_data}

`; } let html = '
'; 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 += `
🚉 ${stationName}
${distance ? `
📏 ${labels.distance} ${distance}
` : ''} ${walkTimeText ? `
🚶 ${labels.walking_time} ${walkTimeText}
` : ''}
`; }); html += '
'; return html; } function renderYouBikeStations(stations) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!stations || stations.length === 0) { return `

${labels.no_data}

`; } let html = '
'; 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 += `
🚲 ${stationName}
📍 ${labels.distance} ${distance}m (${walkText})
🚴 ${labels.available_bikes} ${bikeStatusIcon} ${availableBikes} ${bikeUnit}
🅿️ ${labels.available_spaces} ${availableSpaces} ${spaceUnit}
ℹ️ ${labels.bike_type} ${bikeType} (${serviceStatus})
`; }); html += '
'; return html; } function renderBusArrivals(arrivals, routeName) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!arrivals || arrivals.length === 0) { return `

${labels.no_data}

`; } 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 += `
🚏 ${stopName} ${distance ? `${distance}` : ''}
`; 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 += `
${directionText} ${status}
`; }); html += `
`; }); 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 `
📍 ${labels.location} ${displayName}
${city ? `
🏙️ ${labels.city} ${city}
` : ''} ${road ? `
🛣️ ${labels.road} ${road}${houseNumber ? ' ' + houseNumber : ''}
` : ''} ${suburb ? `
🏘️ ${labels.area} ${suburb}
` : ''}
🌐 ${labels.coordinates} ${lat}, ${lon}
🗺️ ${labels.view_in_maps} →
`; } function renderNearbyStops(stops) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!stops || stops.length === 0) { return `

${labels.no_data}

`; } 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 += `
🚏 ${stopName}
${walkTimeText} ${distance ? `(${distance})` : ''}
`; }); 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 = `
🗺️ ${labels.view_in_maps} →
`; } return `
📍 ${labels.origin} ${originLabel}
🎯 ${labels.destination} ${destLabel}
📏 ${labels.distance} ${distanceStr}
⏱️ ${labels.estimated_time} ${durationStr}
${mapsLink} `; } function renderMetroArrivals(arrivals) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!arrivals || arrivals.length === 0) { return `

${labels.no_data}

`; } let html = '
'; 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 += `
🚇 ${lineName}
`; 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 += `
→ ${dest} ${timeStr}
`; }); html += '
'; }); html += '
'; return html; } function renderMetroStations(stations) { const labels = LABELS[currentLanguage] || LABELS.zh; if (!stations || stations.length === 0) { return `

${labels.no_data}

`; } let html = '
'; 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 += `
🚇 ${stationName}
${distance ? `
📏 ${labels.distance} ${distance}
` : ''} ${walkTimeText ? `
🚶 ${labels.walking_time} ${walkTimeText}
` : ''} ${address ? `
📍 ${labels.address} ${address}
` : ''}
`; }); html += '
'; 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 `
📍 ${labels.location} ${displayName}
${city ? `
🏙️ ${labels.city} ${city}
` : ''} ${road ? `
🛣️ ${labels.road} ${road}
` : ''} ${suburb ? `
🏘️ ${labels.area} ${suburb}
` : ''}
🌐 ${labels.coordinates} ${lat}, ${lon}
🗺️ ${labels.view_in_maps} →
`; } function renderJSONFallback(data) { return `
${JSON.stringify(data, null, 2)}
`; }