/** Lingniu Vehicle Map — live data from the vehicle open platform. */ const i18n = { zh: { vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络', kpiTotalFleet: '车辆总数', kpiOnlineFleet: '当前在线运行', kpiDailyMileage: '今日运营里程', unitVehicles: '辆', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站', themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)', legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角', btn3dView: '3D/2D 视角', btnNationalView: '全国视图', mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)', mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势', panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布', panelRankVehicle: '省级车辆分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名', statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线', stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台' }, en: { vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network', kpiTotalFleet: 'Total Fleet', kpiOnlineFleet: 'Active Online', kpiDailyMileage: 'Today Mileage', unitVehicles: 'units', unitStations: 'stations', btnVehicle: 'Fleet', btnStation: 'H₂ Stations', themeDark: 'Dark', themeLight: 'Light', legendPrimary: 'Core (>100)', legendMid: 'Key (20-100)', legendLow: 'Standard (<20)', btnResetView: 'Reset View', btn3dView: '3D/2D View', btnNationalView: 'National View', mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)', mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview', panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown', panelRankVehicle: 'Top Provinces by Fleet', panelRankStation: 'Top Provinces by Stations', statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline', stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations', rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units' } }; // 车牌首字对应省级行政区。全国视图按车辆注册地聚合,因此离线、无定位车辆也会计入。 const PROVINCE_BY_PLATE_PREFIX = { 京: ['北京', 'Beijing', 116.407526, 39.904030], 津: ['天津', 'Tianjin', 117.200983, 39.084158], 沪: ['上海', 'Shanghai', 121.473701, 31.230416], 渝: ['重庆', 'Chongqing', 106.551556, 29.563009], 冀: ['河北', 'Hebei', 114.514859, 38.042306], 豫: ['河南', 'Henan', 113.665412, 34.757975], 云: ['云南', 'Yunnan', 102.712251, 25.040609], 辽: ['辽宁', 'Liaoning', 123.429096, 41.796767], 黑: ['黑龙江', 'Heilongjiang', 126.642464, 45.756967], 湘: ['湖南', 'Hunan', 112.982279, 28.194090], 皖: ['安徽', 'Anhui', 117.283042, 31.861190], 鲁: ['山东', 'Shandong', 117.000923, 36.675807], 新: ['新疆', 'Xinjiang', 87.617733, 43.792818], 苏: ['江苏', 'Jiangsu', 118.796877, 32.060255], 浙: ['浙江', 'Zhejiang', 120.155070, 30.274085], 赣: ['江西', 'Jiangxi', 115.892151, 28.676493], 鄂: ['湖北', 'Hubei', 114.305393, 30.593099], 桂: ['广西', 'Guangxi', 108.320004, 22.824020], 甘: ['甘肃', 'Gansu', 103.823557, 36.058039], 晋: ['山西', 'Shanxi', 112.549248, 37.857014], 蒙: ['内蒙古', 'Inner Mongolia', 111.670801, 40.818311], 陕: ['陕西', 'Shaanxi', 108.948024, 34.263161], 吉: ['吉林', 'Jilin', 125.324500, 43.886841], 闽: ['福建', 'Fujian', 119.306239, 26.075302], 贵: ['贵州', 'Guizhou', 106.713478, 26.578343], 粤: ['广东', 'Guangdong', 113.264385, 23.129112], 青: ['青海', 'Qinghai', 101.778916, 36.623178], 藏: ['西藏', 'Tibet', 91.132212, 29.660361], 川: ['四川', 'Sichuan', 104.066541, 30.572269], 宁: ['宁夏', 'Ningxia', 106.278179, 38.466370], 琼: ['海南', 'Hainan', 110.198293, 20.044001], 港: ['香港', 'Hong Kong', 114.169361, 22.319303], 澳: ['澳门', 'Macao', 113.543873, 22.198745], 台: ['台湾', 'Taiwan', 121.509062, 25.044332] }; let map = null; let markerList = []; let infoWindowList = []; let is3DPitch = true; let currentMode = 'vehicle'; let currentTheme = 'theme-white'; let currentLang = 'zh'; let currentRankType = 'fleet'; let dashboard = null; let refreshTimer = null; document.addEventListener('DOMContentLoaded', () => { initClock(); initAMapInstance(); loadDashboard(); refreshTimer = window.setInterval(loadDashboard, 30000); }); async function loadDashboard() { setDataState('loading'); try { const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } }); const payload = await response.json(); if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); dashboard = payload; updateDashboardUI(); setDataState('ready'); } catch (error) { console.error('dashboard refresh failed', error); setDataState('error'); } } function setDataState(state) { const el = document.getElementById('mapStatusText'); if (!el) return; if (state === 'loading' && !dashboard) el.textContent = currentLang === 'zh' ? '正在同步开放平台数据…' : 'Syncing open-platform data…'; if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying'; } function initAMapInstance() { if (typeof AMap === 'undefined') { setDataState('error'); return; } map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true }); map.on('complete', renderAMapMarkers); map.on('zoomend', renderAMapMarkers); } function updateDashboardUI() { if (!dashboard) return; const dict = i18n[currentLang]; const summary = dashboard.summary; const total = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations; const active = currentMode === 'vehicle' ? summary.onlineVehicles : summary.cooperativeStations; const unit = currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations; const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0'; document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle; document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} ${unit}`; document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} ${unit} (${activeRate}%)`; document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} km`; const provinceSummary = buildVehicleProvinceNodes(); document.getElementById('mapStatusText').textContent = currentLang === 'zh' ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${provinceSummary.nodes.length}个省级行政区${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned}辆待归属` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}` : `Open platform synced · ${summary.totalVehicles} vehicles · ${provinceSummary.nodes.length} province-level regions${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned} unassigned` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`; updateStatusPanel(); renderRankingList(currentRankType); renderAMapMarkers(); } function updateStatusPanel() { const dict = i18n[currentLang]; const summary = dashboard.summary; const title = document.getElementById('panelStatusTitle'); const labels = [document.getElementById('statusLabel1'), document.getElementById('statusLabel2'), document.getElementById('statusLabel3')]; const values = [document.getElementById('statusValue1'), document.getElementById('statusValue2'), document.getElementById('statusValue3')]; let counts; if (currentMode === 'vehicle') { title.textContent = dict.panelStatusVehicle; counts = [summary.drivingVehicles, summary.idleVehicles, summary.offlineVehicles]; labels[0].textContent = dict.statusRunning; labels[1].textContent = dict.statusStopped; labels[2].textContent = dict.statusOffline; } else { title.textContent = dict.panelStatusStation; counts = [summary.cooperativeStations, summary.totalStations - summary.cooperativeStations, summary.totalStations]; labels[0].textContent = dict.stationCooperative; labels[1].textContent = dict.stationExternal; labels[2].textContent = dict.stationTotal; } const denominator = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations; values.forEach((element, index) => { const pct = denominator ? counts[index] * 100 / denominator : 0; element.innerHTML = `${formatNumber(counts[index])} ${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}`; const pctElement = document.getElementById(`statusPct${index + 1}`); if (pctElement) pctElement.textContent = `(${pct.toFixed(1)}%)`; }); const segments = document.querySelectorAll('.liquid-progress-bar .seg'); if (segments.length === 3) { const segmentCounts = currentMode === 'vehicle' ? counts : [counts[0], counts[1], 0]; segments.forEach((segment, index) => segment.style.width = `${denominator ? segmentCounts[index] * 100 / denominator : 0}%`); } document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? dict.panelRankVehicle : dict.panelRankStation; } function buildVehicleClusters() { const clusters = new Map(); for (const vehicle of dashboard?.vehicles || []) { if (!vehicle.locationAvailable || vehicle.longitude == null || vehicle.latitude == null) continue; const key = `${Math.round(vehicle.longitude * 2) / 2},${Math.round(vehicle.latitude * 2) / 2}`; if (!clusters.has(key)) clusters.set(key, { kind: 'vehicleCluster', lng: 0, lat: 0, count: 0, online: 0, dist: 0, vehicles: [] }); const cluster = clusters.get(key); cluster.lng += Number(vehicle.longitude); cluster.lat += Number(vehicle.latitude); cluster.count += 1; cluster.online += vehicle.online ? 1 : 0; cluster.dist += Number(vehicle.dailyMileageKm || 0); cluster.vehicles.push(vehicle); } return [...clusters.values()].map((cluster, index) => ({ ...cluster, id: `vehicle-${index}`, lnglat: [cluster.lng / cluster.count, cluster.lat / cluster.count], name: currentLang === 'zh' ? '车辆集群' : 'Vehicle cluster', detail: cluster.vehicles.slice(0, 5).map(v => v.plateNumber || v.vin).join('、') })); } function provinceMetaForVehicle(vehicle) { const plate = String(vehicle?.plateNumber || '').trim(); const raw = PROVINCE_BY_PLATE_PREFIX[plate.charAt(0)]; if (!raw) return null; return { nameZh: raw[0], nameEn: raw[1], lnglat: [raw[2], raw[3]] }; } function buildVehicleProvinceNodes() { const grouped = new Map(); let unassigned = 0; for (const vehicle of dashboard?.vehicles || []) { const province = provinceMetaForVehicle(vehicle); if (!province) { unassigned += 1; continue; } if (!grouped.has(province.nameZh)) { grouped.set(province.nameZh, { kind: 'vehicleProvince', nameZh: province.nameZh, nameEn: province.nameEn, lnglat: province.lnglat, count: 0, online: 0, dist: 0, vehicles: [] }); } const group = grouped.get(province.nameZh); group.count += 1; group.online += vehicle.online ? 1 : 0; group.dist += Number(vehicle.dailyMileageKm || 0); group.vehicles.push(vehicle); } const nodes = [...grouped.values()].map((group, index) => ({ ...group, id: `vehicle-province-${index}`, name: currentLang === 'zh' ? group.nameZh : group.nameEn, detail: group.vehicles.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、') })); return { nodes, unassigned }; } function vehicleNodes() { if ((map?.getZoom?.() || 0) < 7) return buildVehicleProvinceNodes().nodes; return buildVehicleClusters(); } function stationNodes() { const stations = dashboard?.stations || []; if ((map?.getZoom?.() || 0) >= 7) { return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude], count: 1, online: station.cooperative ? 1 : 0, detail: [station.province, station.city, station.district, station.address].filter(Boolean).join(' '), cooperative: station.cooperative })); } const grouped = new Map(); for (const station of stations) { const province = station.province || (currentLang === 'zh' ? '未标注区域' : 'Unspecified'); if (!grouped.has(province)) grouped.set(province, { kind: 'stationCluster', name: province, lng: 0, lat: 0, count: 0, online: 0, stations: [] }); const group = grouped.get(province); group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); } return [...grouped.values()].map((group, index) => ({ ...group, id: `station-cluster-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') })); } function renderAMapMarkers() { if (!map || !dashboard) return; markerList.forEach(marker => marker.remove()); markerList = []; infoWindowList.forEach(infoWindow => infoWindow.close()); infoWindowList = []; const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes(); const dict = i18n[currentLang]; for (const node of nodes) { const isVehicleNode = node.kind === 'vehicleProvince' || node.kind === 'vehicleCluster'; const markerContent = document.createElement('div'); markerContent.className = 'province-info-badge-wrap'; const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂') : node.kind === 'stationCluster' ? `${node.count} 站` : node.kind === 'vehicleProvince' ? `${node.count} ${dict.unitVehicles}` : `${node.count}`; markerContent.innerHTML = `
${escapeHTML(node.name)}${escapeHTML(label)}
${isVehicleNode ? `
${dict.onlineText} ${node.online}
` : ''}
`; const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name }); const infoWindow = new AMap.InfoWindow({ isCustom: true, content: `
${escapeHTML(node.name)}
${escapeHTML(node.detail || '')}
${isVehicleNode ? `
${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km
` : ''}
`, offset: new AMap.Pixel(0, -32) }); infoWindowList.push(infoWindow); marker.on('mouseover', () => infoWindow.open(map, node.lnglat)); marker.on('mouseout', () => infoWindow.close()); if (node.kind === 'vehicleProvince') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat)); if (node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat)); marker.setMap(map); markerList.push(marker); } } function renderRankingList(type) { const box = document.getElementById('rankingListBox'); if (!box || !dashboard) return; box.innerHTML = ''; let rows; if (currentMode === 'vehicle') { rows = buildVehicleProvinceNodes().nodes .sort((a, b) => type === 'dist' ? b.dist - a.dist : b.count - a.count) .slice(0, 12) .map(item => ({ name: item.name, value: type === 'dist' ? `${formatNumber(item.dist, 1)} km` : `${formatNumber(item.count)} ${i18n[currentLang].unitVehicles}`, location: item.lnglat })); } else { const provinces = new Map(); for (const station of dashboard.stations) { const name = station.province || (currentLang === 'zh' ? '未标注' : 'Unspecified'); provinces.set(name, (provinces.get(name) || 0) + 1); } rows = [...provinces.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([name, value]) => ({ name, value: `${value} ${i18n[currentLang].unitStations}` })); } rows.forEach((item, index) => { const row = document.createElement('div'); row.className = 'rank-glass-row'; row.innerHTML = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; if (item.location) row.onclick = () => map?.setZoomAndCenter(9, item.location); box.appendChild(row); }); } function switchRankTab(type) { currentRankType = type; document.querySelectorAll('.gtab').forEach(button => button.classList.remove('active')); if (window.event?.target) window.event.target.classList.add('active'); renderRankingList(type); } function switchMode(mode) { currentMode = mode; document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle'); document.getElementById('btnModeStation').classList.toggle('active', mode === 'station'); updateDashboardUI(); } function setLanguage(lang) { currentLang = lang; document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active')); document.querySelector(`.lang-${lang}-btn`)?.classList.add('active'); const dict = i18n[lang]; document.querySelectorAll('[data-i18n]').forEach(element => { const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key]; }); updateDashboardUI(); } function setTheme(themeName) { currentTheme = themeName; document.body.className = themeName; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(themeName === 'theme-dark' ? '.theme-dark-btn' : '.theme-white-btn')?.classList.add('active'); if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); } } function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; } function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } } function clearProvinceFilter() { resetMapView(); } function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } } function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function initClock() { const clock = document.getElementById('clockTime'); const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); }