fix(map): group national view by live GPS province

This commit is contained in:
lingniu
2026-08-03 21:49:42 +08:00
parent 30fef937b0
commit dc5f88cf16
6 changed files with 153 additions and 60 deletions
+1 -1
View File
@@ -3,5 +3,5 @@ VEHICLE_MAP_PORT=20800
OPEN_PLATFORM_BASE_URL=https://open.d.lnoneos.com OPEN_PLATFORM_BASE_URL=https://open.d.lnoneos.com
OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key
UPSTREAM_TIMEOUT_SECONDS=15 UPSTREAM_TIMEOUT_SECONDS=15
DASHBOARD_CACHE_SECONDS=20 DASHBOARD_CACHE_SECONDS=5
STATION_CACHE_SECONDS=3600 STATION_CACHE_SECONDS=3600
+3 -1
View File
@@ -30,4 +30,6 @@ python3 server.py
本服务提供: 本服务提供:
- `GET /api/health`:进程及配置状态; - `GET /api/health`:进程及配置状态;
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存20秒;加氢站缓存1小时。 - `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
+131 -51
View File
@@ -33,25 +33,13 @@ const i18n = {
} }
}; };
// 车牌首字对应省级行政区。全国视图按车辆注册地聚合,因此离线、无定位车辆也会计入。 const PROVINCE_ENGLISH = {
const PROVINCE_BY_PLATE_PREFIX = { 北京: 'Beijing', 天津: 'Tianjin', 上海: 'Shanghai', 重庆: 'Chongqing', 河北: 'Hebei', 河南: 'Henan',
: ['北京', 'Beijing', 116.407526, 39.904030], : ['天津', 'Tianjin', 117.200983, 39.084158], 云南: 'Yunnan', 辽宁: 'Liaoning', 黑龙江: 'Heilongjiang', 湖南: 'Hunan', 安徽: 'Anhui', 山东: 'Shandong',
: ['上海', 'Shanghai', 121.473701, 31.230416], : ['重庆', 'Chongqing', 106.551556, 29.563009], 新疆: 'Xinjiang', 江苏: 'Jiangsu', 浙江: 'Zhejiang', 江西: 'Jiangxi', 湖北: 'Hubei', 广西: 'Guangxi',
: ['河北', 'Hebei', 114.514859, 38.042306], : ['河南', 'Henan', 113.665412, 34.757975], 甘肃: 'Gansu', 山西: 'Shanxi', 内蒙古: 'Inner Mongolia', 陕西: 'Shaanxi', 吉林: 'Jilin', 福建: 'Fujian',
: ['云南', 'Yunnan', 102.712251, 25.040609], : ['辽宁', 'Liaoning', 123.429096, 41.796767], 贵州: 'Guizhou', 广东: 'Guangdong', 青海: 'Qinghai', 西藏: 'Tibet', 四川: 'Sichuan', 宁夏: 'Ningxia',
: ['黑龙江', 'Heilongjiang', 126.642464, 45.756967], : ['湖南', 'Hunan', 112.982279, 28.194090], 海南: 'Hainan', 香港: 'Hong Kong', 澳门: 'Macao', 台湾: 'Taiwan'
: ['安徽', '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 map = null;
@@ -64,12 +52,15 @@ let currentLang = 'zh';
let currentRankType = 'fleet'; let currentRankType = 'fleet';
let dashboard = null; let dashboard = null;
let refreshTimer = null; let refreshTimer = null;
let provinceAreaNodePromise = null;
let provinceAggregationVersion = 0;
let vehicleProvinceSummary = { nodes: [], unassigned: 0, loading: true };
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initClock(); initClock();
initAMapInstance(); initAMapInstance();
loadDashboard(); loadDashboard();
refreshTimer = window.setInterval(loadDashboard, 30000); refreshTimer = window.setInterval(loadDashboard, 15000);
}); });
async function loadDashboard() { async function loadDashboard() {
@@ -80,6 +71,7 @@ async function loadDashboard() {
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
dashboard = payload; dashboard = payload;
updateDashboardUI(); updateDashboardUI();
refreshVehicleProvinceNodes(payload);
setDataState('ready'); setDataState('ready');
} catch (error) { } catch (error) {
console.error('dashboard refresh failed', error); console.error('dashboard refresh failed', error);
@@ -120,9 +112,12 @@ function updateDashboardUI() {
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${unit} (${activeRate}%)</small>`; document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${unit} (${activeRate}%)</small>`;
document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} <small>km</small>`; document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} <small>km</small>`;
const provinceSummary = buildVehicleProvinceNodes(); const provinceSummary = buildVehicleProvinceNodes();
const provinceText = provinceSummary.loading
? (currentLang === 'zh' ? '省级归属计算中' : 'province grouping in progress')
: (currentLang === 'zh' ? `${provinceSummary.nodes.length}个省级行政区` : `${provinceSummary.nodes.length} province-level regions`);
document.getElementById('mapStatusText').textContent = currentLang === 'zh' document.getElementById('mapStatusText').textContent = currentLang === 'zh'
? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${provinceSummary.nodes.length}个省级行政区${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned}待归属` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}` ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${provinceText}${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}`; : `Open platform synced · ${summary.totalVehicles} vehicles · ${provinceText}${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned} without realtime location` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`;
updateStatusPanel(); updateStatusPanel();
renderRankingList(currentRankType); renderRankingList(currentRankType);
renderAMapMarkers(); renderAMapMarkers();
@@ -175,38 +170,123 @@ function buildVehicleClusters() {
})); }));
} }
function provinceMetaForVehicle(vehicle) { function compactProvinceName(name) {
const plate = String(vehicle?.plateNumber || '').trim(); return String(name || '')
const raw = PROVINCE_BY_PLATE_PREFIX[plate.charAt(0)]; .replace(/(壮族|回族|维吾尔)?自治区$/, '')
if (!raw) return null; .replace(/特别行政区$/, '')
return { nameZh: raw[0], nameEn: raw[1], lnglat: [raw[2], raw[3]] }; .replace(/[省市]$/, '');
}
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 chinaProvinceAreaNode() {
if (provinceAreaNodePromise) return provinceAreaNodePromise;
provinceAreaNodePromise = loadAMapUIScript().then(() => new Promise((resolve, reject) => {
window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => {
const explorer = new DistrictExplorer({ eventSupport: false });
explorer.loadAreaNode(100000, (error, areaNode) => {
if (error || !areaNode) reject(error || new Error('全国省级边界不可用'));
else resolve(areaNode);
});
});
})).catch(error => {
provinceAreaNodePromise = null;
throw error;
});
return provinceAreaNodePromise;
}
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 aggregateVehicleProvinces(areaNode, vehicles = dashboard?.vehicles || []) {
const located = vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude)));
let unassigned = vehicles.length - located.length;
const nodes = [];
const groups = areaNode.groupByPosition(located, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
for (const group of groups) {
if (!group.points?.length) continue;
const feature = group.subFeature;
if (group.subFeatureIndex < 0 || !feature) { unassigned += group.points.length; continue; }
const nameZh = compactProvinceName(feature.properties.name);
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
];
nodes.push({
id: `vehicle-province-${feature.properties.adcode}`, kind: 'vehicleProvince', nameZh,
nameEn: PROVINCE_ENGLISH[nameZh] || nameZh, name: currentLang === 'zh' ? nameZh : (PROVINCE_ENGLISH[nameZh] || nameZh),
lnglat: center, count: group.points.length, online: group.points.filter(vehicle => vehicle.online).length,
dist: group.points.reduce((sum, vehicle) => sum + Number(vehicle.dailyMileageKm || 0), 0), vehicles: group.points,
detail: group.points.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、')
});
}
return { nodes, unassigned, loading: false };
}
async function refreshVehicleProvinceNodes(sourceDashboard = dashboard) {
const version = ++provinceAggregationVersion;
try {
const areaNode = await chinaProvinceAreaNode();
if (version !== provinceAggregationVersion || sourceDashboard !== dashboard) return;
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode, sourceDashboard?.vehicles || []);
updateDashboardUI();
} catch (error) {
console.error('province aggregation failed', error);
if (version !== provinceAggregationVersion) return;
vehicleProvinceSummary = { nodes: [], unassigned: sourceDashboard?.vehicles?.length || 0, loading: false };
updateDashboardUI();
}
} }
function buildVehicleProvinceNodes() { function buildVehicleProvinceNodes() {
const grouped = new Map(); return {
let unassigned = 0; ...vehicleProvinceSummary,
for (const vehicle of dashboard?.vehicles || []) { nodes: vehicleProvinceSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
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() { function vehicleNodes() {
+2 -2
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css?v=2026080322">
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration --> <!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
<script type="text/javascript"> <script type="text/javascript">
@@ -188,6 +188,6 @@
</div> </div>
<script src="app.js"></script> <script src="app.js?v=2026080322"></script>
</body> </body>
</html> </html>
+3 -1
View File
@@ -18,7 +18,7 @@ PORT = int(os.getenv("VEHICLE_MAP_PORT", "20800"))
OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/") OPEN_PLATFORM_BASE_URL = os.getenv("OPEN_PLATFORM_BASE_URL", "https://open.d.lnoneos.com").rstrip("/")
OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip() OPEN_PLATFORM_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15")) UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("UPSTREAM_TIMEOUT_SECONDS", "15"))
DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "20")) DASHBOARD_CACHE_SECONDS = int(os.getenv("DASHBOARD_CACHE_SECONDS", "5"))
STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600")) STATION_CACHE_SECONDS = int(os.getenv("STATION_CACHE_SECONDS", "3600"))
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
@@ -152,6 +152,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
super().do_GET() super().do_GET()
def end_headers(self): def end_headers(self):
if self.path == "/" or self.path.split("?", 1)[0].endswith((".html", ".css", ".js", ".svg")):
self.send_header("Cache-Control", "no-cache")
self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "strict-origin-when-cross-origin") self.send_header("Referrer-Policy", "strict-origin-when-cross-origin")
self.send_header("X-Frame-Options", "SAMEORIGIN") self.send_header("X-Frame-Options", "SAMEORIGIN")
+13 -4
View File
@@ -12,13 +12,22 @@ dashboard = {
{ vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false } { vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false }
] ]
}; };
const areaNode = {
groupByPosition(points) {
return [
{ subFeatureIndex: 0, subFeature: { properties: { adcode: '440000', name: '广东省', center: [113.2, 23.1] } }, points: points.filter(point => point.vin === 'VIN-GD-1') },
{ subFeatureIndex: 1, subFeature: { properties: { adcode: '330000', name: '浙江省', center: [120.2, 30.3] } }, points: points.filter(point => point.vin === 'VIN-ZJ-1') }
];
}
};
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode);
const summary = buildVehicleProvinceNodes(); const summary = buildVehicleProvinceNodes();
assert.equal(summary.nodes.length, 2); assert.equal(summary.nodes.length, 2);
assert.equal(summary.unassigned, 1); assert.equal(summary.unassigned, 2);
const guangdong = summary.nodes.find(node => node.nameZh === '广东'); const guangdong = summary.nodes.find(node => node.nameZh === '广东');
assert.equal(guangdong.count, 2); assert.equal(guangdong.count, 1);
assert.equal(guangdong.online, 1); assert.equal(guangdong.online, 1);
assert.equal(guangdong.dist, 15); assert.equal(guangdong.dist, 10);
map = { getZoom: () => 4.8 }; map = { getZoom: () => 4.8 };
assert.equal(vehicleNodes().length, 2); assert.equal(vehicleNodes().length, 2);
@@ -32,7 +41,7 @@ assert.equal(vehicleNodes().reduce((sum, node) => sum + node.count, 0), 2);
const sandbox = { const sandbox = {
assert, assert,
console, console,
document: { addEventListener() {} }, document: { addEventListener() {}, querySelector() {}, createElement() { return {}; }, head: { appendChild() {} } },
window: {}, window: {},
setInterval() {}, setInterval() {},
clearInterval() {} clearInterval() {}