fix: aggregate national fleet by province

This commit is contained in:
lingniu
2026-08-03 21:23:54 +08:00
parent 53506afbde
commit 72dae44a31
3 changed files with 117 additions and 14 deletions
+75 -13
View File
@@ -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)} <small>${unit}</small>`;
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${unit} (${activeRate}%)</small>`;
document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} <small>km</small>`;
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 = `<div class="province-info-card"><div class="p-head"><span class="p-dot"></span><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${node.kind === 'vehicle' ? `<div class="p-sub">${dict.onlineText} ${node.online}</div>` : ''}</div>`;
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 = `<div class="province-info-card"><div class="p-head"><span class="p-dot"></span><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${isVehicleNode ? `<div class="p-sub">${dict.onlineText} ${node.online}</div>` : ''}</div>`;
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: `<div class="map-info-window"><strong>${escapeHTML(node.name)}</strong><div>${escapeHTML(node.detail || '')}</div>${node.kind === 'vehicle' ? `<div>${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km</div>` : ''}</div>`,
content: `<div class="map-info-window"><strong>${escapeHTML(node.name)}</strong><div>${escapeHTML(node.detail || '')}</div>${isVehicleNode ? `<div>${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km</div>` : ''}</div>`,
offset: new AMap.Pixel(0, -32)
});
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);
}
@@ -204,11 +267,10 @@ function renderRankingList(type) {
box.innerHTML = '';
let rows;
if (currentMode === 'vehicle') {
rows = [...dashboard.vehicles]
.filter(item => item.totalMileageKm != null)
.sort((a, b) => type === 'dist' ? Number(b.dailyMileageKm || 0) - Number(a.dailyMileageKm || 0) : Number(b.totalMileageKm || 0) - Number(a.totalMileageKm || 0))
rows = buildVehicleProvinceNodes().nodes
.sort((a, b) => type === 'dist' ? b.dist - a.dist : b.count - a.count)
.slice(0, 12)
.map(item => ({ name: item.plateNumber || item.vin, value: type === 'dist' ? `${formatNumber(item.dailyMileageKm, 1)} km` : `${formatNumber(item.totalMileageKm, 1)} km`, location: item.locationAvailable ? [item.longitude, item.latitude] : null }));
.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) {
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"start": "python3 server.py",
"dev": "VEHICLE_MAP_PORT=20800 python3 server.py",
"test": "python3 -m unittest discover -s tests -v"
"test": "python3 -m unittest discover -s tests -v && node tests/test_app.mjs"
},
"keywords": ["lingniu", "hydrogen", "map", "cockpit"],
"author": "Antigravity",
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
const checks = `
dashboard = {
vehicles: [
{ vin: 'VIN-GD-1', plateNumber: '粤A00001', online: true, dailyMileageKm: 10, locationAvailable: true, longitude: 113.2, latitude: 23.1 },
{ vin: 'VIN-GD-2', plateNumber: '粤B00002', online: false, dailyMileageKm: 5, locationAvailable: false },
{ vin: 'VIN-ZJ-1', plateNumber: '浙F00003', online: true, dailyMileageKm: 8, locationAvailable: true, longitude: 120.7, latitude: 30.7 },
{ vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false }
]
};
const summary = buildVehicleProvinceNodes();
assert.equal(summary.nodes.length, 2);
assert.equal(summary.unassigned, 1);
const guangdong = summary.nodes.find(node => node.nameZh === '广东');
assert.equal(guangdong.count, 2);
assert.equal(guangdong.online, 1);
assert.equal(guangdong.dist, 15);
map = { getZoom: () => 4.8 };
assert.equal(vehicleNodes().length, 2);
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince'));
map = { getZoom: () => 8 };
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleCluster'));
assert.equal(vehicleNodes().reduce((sum, node) => sum + node.count, 0), 2);
`;
const sandbox = {
assert,
console,
document: { addEventListener() {} },
window: {},
setInterval() {},
clearInterval() {}
};
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
console.log('vehicle province aggregation tests: ok');