/** 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 Fleet Regions', 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_ENGLISH = { 北京: 'Beijing', 天津: 'Tianjin', 上海: 'Shanghai', 重庆: 'Chongqing', 河北: 'Hebei', 河南: 'Henan', 云南: 'Yunnan', 辽宁: 'Liaoning', 黑龙江: 'Heilongjiang', 湖南: 'Hunan', 安徽: 'Anhui', 山东: 'Shandong', 新疆: 'Xinjiang', 江苏: 'Jiangsu', 浙江: 'Zhejiang', 江西: 'Jiangxi', 湖北: 'Hubei', 广西: 'Guangxi', 甘肃: 'Gansu', 山西: 'Shanxi', 内蒙古: 'Inner Mongolia', 陕西: 'Shaanxi', 吉林: 'Jilin', 福建: 'Fujian', 贵州: 'Guizhou', 广东: 'Guangdong', 青海: 'Qinghai', 西藏: 'Tibet', 四川: 'Sichuan', 宁夏: 'Ningxia', 海南: 'Hainan', 香港: 'Hong Kong', 澳门: 'Macao', 台湾: 'Taiwan' }; 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; let districtExplorerPromise = null; const areaNodePromises = new Map(); let regionAggregationVersion = 0; let vehicleRegionSummary = { level: 'province', nodes: [], unassigned: 0, loading: true }; const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 }; const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); document.addEventListener('DOMContentLoaded', () => { initClock(); initAMapInstance(); loadDashboard(); refreshTimer = window.setInterval(loadDashboard, 15000); }); 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(); refreshVehicleRegionNodes(payload); 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', () => { if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard); else { renderRankingList(currentRankType); renderAMapMarkers(); } }); map.on('moveend', () => { if (currentMode === 'vehicle' && hierarchyLevelForZoom() !== 'province') refreshVehicleRegionNodes(dashboard); }); } 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 regionSummary = buildVehicleRegionNodes(); const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level) : regionSummaryText(regionSummary); document.getElementById('mapStatusText').textContent = currentLang === 'zh' ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned}辆无实时位置` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}` : `Open platform synced · ${summary.totalVehicles} vehicles · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned} without realtime location` : ''} · ${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' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation; } function compactProvinceName(name) { return String(name || '') .replace(/(壮族|回族|维吾尔)?自治区$/, '') .replace(/特别行政区$/, '') .replace(/[省市]$/, ''); } function compactRegionName(name) { return compactProvinceName(name) .replace(/自治州$/, '') .replace(/地区$/, '') .replace(/林区$/, '') .replace(/[盟区县市]$/, ''); } function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) { if (zoom < REGION_ZOOM.city) return 'province'; if (zoom < REGION_ZOOM.district) return 'city'; if (zoom < REGION_ZOOM.vehicle) return 'district'; return 'vehicle'; } function regionLoadingText(level) { const zh = { province: '省级归属计算中', city: '市级归属计算中', district: '区县级归属计算中', vehicle: '单车定位计算中' }; const en = { province: 'province grouping in progress', city: 'city grouping in progress', district: 'district grouping in progress', vehicle: 'vehicle positioning in progress' }; return (currentLang === 'zh' ? zh : en)[level]; } function regionSummaryText(summary) { const zh = { province: '个省级行政区', city: '个当前视野城市', district: '个当前视野区县', vehicle: '辆当前视野车辆' }; const en = { province: ' province-level regions', city: ' cities in view', district: ' districts in view', vehicle: ' vehicles in view' }; return `${summary.nodes.length}${(currentLang === 'zh' ? zh : en)[summary.level]}`; } function vehicleRankTitle(level) { if (currentLang === 'en') return ({ province: 'Top Provinces by Fleet', city: 'Top Cities by Fleet', district: 'Top Districts by Fleet', vehicle: 'Vehicles in Current View' })[level]; return ({ province: '省级车辆分布 TOP 排名', city: '市级车辆分布 TOP 排名', district: '区县级车辆分布 TOP 排名', vehicle: '当前视野车辆' })[level]; } function loadAMapUIScript() { const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1'; if (window.AMapUI) return Promise.resolve(); return new Promise((resolve, reject) => { const existing = document.querySelector?.(`script[src="${src}"]`); if (existing) { existing.addEventListener('load', resolve, { once: true }); existing.addEventListener('error', () => reject(new Error('高德行政区组件加载失败')), { once: true }); return; } const script = document.createElement('script'); script.src = src; script.async = true; script.onload = resolve; script.onerror = () => reject(new Error('高德行政区组件加载失败')); document.head.appendChild(script); }); } function districtExplorer() { if (districtExplorerPromise) return districtExplorerPromise; districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => { window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => { resolve(new DistrictExplorer({ eventSupport: false })); }); })).catch(error => { districtExplorerPromise = null; throw error; }); return districtExplorerPromise; } function areaNodeFor(adcode) { const key = String(adcode); if (areaNodePromises.has(key)) return areaNodePromises.get(key); const promise = districtExplorer().then(explorer => new Promise((resolve, reject) => { explorer.loadAreaNode(Number(adcode), (error, areaNode) => { if (error || !areaNode) reject(error || new Error(`行政区边界不可用: ${adcode}`)); else resolve(areaNode); }); })).catch(error => { areaNodePromises.delete(key); throw error; }); areaNodePromises.set(key, promise); return promise; } const GCJ_A = 6378245; const GCJ_EE = 0.006693421622965943; function outsideGcjChina(longitude, latitude) { return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271; } function transformLatitude(longitude, latitude) { let value = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude)); value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3; value += (20 * Math.sin(latitude * Math.PI) + 40 * Math.sin(latitude / 3 * Math.PI)) * 2 / 3; value += (160 * Math.sin(latitude / 12 * Math.PI) + 320 * Math.sin(latitude * Math.PI / 30)) * 2 / 3; return value; } function transformLongitude(longitude, latitude) { let value = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude)); value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3; value += (20 * Math.sin(longitude * Math.PI) + 40 * Math.sin(longitude / 3 * Math.PI)) * 2 / 3; value += (150 * Math.sin(longitude / 12 * Math.PI) + 300 * Math.sin(longitude / 30 * Math.PI)) * 2 / 3; return value; } function wgs84ToGcj02(longitude, latitude) { if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) return [longitude, latitude]; const latitudeOffset = transformLatitude(longitude - 105, latitude - 35); const longitudeOffset = transformLongitude(longitude - 105, latitude - 35); const radianLatitude = latitude / 180 * Math.PI; const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2; const sqrtMagic = Math.sqrt(magic); return [ longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI), latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI) ]; } function locatedVehicles(vehicles = dashboard?.vehicles || []) { return vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude))); } function partitionVehiclesByArea(areaNode, vehicles, level) { const groups = []; const unmatched = []; for (const group of areaNode.groupByPosition(vehicles, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)))) { if (!group.points?.length) continue; const feature = group.subFeature; if (group.subFeatureIndex < 0 || !feature) { unmatched.push(...group.points); continue; } const rawName = String(feature.properties.name || ''); const nameZh = level === 'province' ? compactProvinceName(rawName) : compactRegionName(rawName); const positions = group.points.map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude))); const center = feature.properties.center || [ positions.reduce((sum, point) => sum + point[0], 0) / positions.length, positions.reduce((sum, point) => sum + point[1], 0) / positions.length ]; groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points }); } return { groups, unmatched }; } function fallbackRegionGroup(parent, vehicles) { return { ...parent, vehicles }; } async function expandRegionGroups(parents, level) { const expanded = await Promise.all(parents.map(async parent => { if (level === 'city' && MUNICIPALITIES.has(compactProvinceName(parent.rawName))) { return [{ ...parent, nameZh: compactProvinceName(parent.rawName) }]; } try { const areaNode = await areaNodeFor(parent.adcode); const partition = partitionVehiclesByArea(areaNode, parent.vehicles, level); if (partition.unmatched.length) partition.groups.push(fallbackRegionGroup(parent, partition.unmatched)); return partition.groups.length ? partition.groups : [parent]; } catch (error) { console.warn(`region expansion failed for ${parent.adcode}`, error); return [parent]; } })); return expanded.flat(); } function regionNodesFromGroups(groups, level) { return groups.map(group => ({ id: `vehicle-${level}-${group.adcode}`, kind: `vehicle${level.charAt(0).toUpperCase()}${level.slice(1)}`, level, adcode: group.adcode, nameZh: group.nameZh, nameEn: level === 'province' ? (PROVINCE_ENGLISH[group.nameZh] || group.nameZh) : group.nameZh, lnglat: group.lnglat, count: group.vehicles.length, online: group.vehicles.filter(vehicle => vehicle.online).length, dist: group.vehicles.reduce((sum, vehicle) => sum + Number(vehicle.dailyMileageKm || 0), 0), vehicles: group.vehicles, detail: group.vehicles.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、') })).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN')); } async function aggregateVehicleHierarchy(level, vehicles = dashboard?.vehicles || []) { const located = locatedVehicles(vehicles); let unassigned = vehicles.length - located.length; const nationalNode = await areaNodeFor(100000); const provincePartition = partitionVehiclesByArea(nationalNode, located, 'province'); unassigned += provincePartition.unmatched.length; if (level === 'province') return { level, nodes: regionNodesFromGroups(provincePartition.groups, level), unassigned, loading: false }; const cityGroups = await expandRegionGroups(provincePartition.groups, 'city'); if (level === 'city') return { level, nodes: regionNodesFromGroups(cityGroups, level), unassigned, loading: false }; const districtGroups = await expandRegionGroups(cityGroups, 'district'); return { level, nodes: regionNodesFromGroups(districtGroups, level), unassigned, loading: false }; } function currentMapBounds() { const bounds = map?.getBounds?.(); const southWest = bounds?.getSouthWest?.(); const northEast = bounds?.getNorthEast?.(); const west = southWest?.getLng?.(); const south = southWest?.getLat?.(); const east = northEast?.getLng?.(); const north = northEast?.getLat?.(); if (![west, south, east, north].every(Number.isFinite)) return null; return { west, south, east, north }; } function vehiclePointNodes(vehicles = dashboard?.vehicles || []) { const bounds = currentMapBounds(); return locatedVehicles(vehicles).map(vehicle => { const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)); return { vehicle, lnglat }; }).filter(({ lnglat }) => !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north)) .map(({ vehicle, lnglat }) => ({ id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle', nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin, lnglat, count: 1, online: vehicle.online ? 1 : 0, dist: Number(vehicle.dailyMileageKm || 0), vehicles: [vehicle], speedKmh: Number(vehicle.speedKmh || 0), protocol: vehicle.protocol || '', detail: [vehicle.vin, vehicle.protocol, vehicle.recordTime].filter(Boolean).join(' · ') })); } function nodesInCurrentView(nodes, level) { if (level === 'province') return nodes; const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); } async function refreshVehicleRegionNodes(sourceDashboard = dashboard) { if (!sourceDashboard) return; const level = hierarchyLevelForZoom(); const version = ++regionAggregationVersion; const unassigned = (sourceDashboard.vehicles || []).length - locatedVehicles(sourceDashboard.vehicles || []).length; if (level === 'vehicle') { vehicleRegionSummary = { level, nodes: vehiclePointNodes(sourceDashboard.vehicles || []), unassigned, loading: false }; updateDashboardUI(); return; } vehicleRegionSummary = { level, nodes: [], unassigned, loading: true }; updateDashboardUI(); try { const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []); if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return; result.nodes = nodesInCurrentView(result.nodes, level); vehicleRegionSummary = result; } catch (error) { console.error('vehicle region aggregation failed', error); if (version !== regionAggregationVersion) return; vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false }; } updateDashboardUI(); } function buildVehicleRegionNodes() { return { ...vehicleRegionSummary, nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn })) }; } function vehicleNodes() { return buildVehicleRegionNodes().nodes; } 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.startsWith('vehicle'); 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 === 'vehiclePoint' ? (node.online ? dict.onlineText : dict.statusOffline) : `${node.count} ${dict.unitVehicles}`; const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : `${dict.onlineText} ${node.online}`; markerContent.innerHTML = `