diff --git a/app.js b/app.js index a05fd1c..852124a 100644 --- a/app.js +++ b/app.js @@ -11,7 +11,7 @@ const i18n = { mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)', mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势', panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布', - panelRankVehicle: '车辆累计里程 TOP 排名', panelRankStation: '加氢站省份 TOP 排名', + panelRankVehicle: '省级车辆分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名', statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线', stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台' @@ -26,13 +26,34 @@ const i18n = { 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 Vehicles by Odometer', panelRankStation: 'Top Provinces by Stations', + 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 is3DPitch = true; @@ -97,9 +118,10 @@ function updateDashboardUI() { 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}辆授权车辆 · ${summary.totalStations}座加氢站 · ${dashboard.asOf}` - : `Open platform synced · ${summary.totalVehicles} vehicles · ${summary.totalStations} stations · ${dashboard.asOf}`; + ? `开放平台已同步 · ${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(); @@ -141,7 +163,7 @@ function buildVehicleClusters() { 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: 'vehicle', lng: 0, lat: 0, count: 0, online: 0, dist: 0, vehicles: [] }); + 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); @@ -152,6 +174,45 @@ function buildVehicleClusters() { })); } +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) { @@ -178,21 +239,23 @@ function stationNodes() { function renderAMapMarkers() { if (!map || !dashboard) return; markerList.forEach(marker => marker.remove()); markerList = []; - const nodes = currentMode === 'vehicle' ? buildVehicleClusters() : stationNodes(); + 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.count}`; - markerContent.innerHTML = `