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 = `
`;
} 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 = `
${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}
`;
}
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 = `
`;
}
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}
`;
}
function renderJSONFallback(data) {
return `${JSON.stringify(data, null, 2)}`;
}