fix(map): group national view by live GPS province
This commit is contained in:
+1
-1
@@ -3,5 +3,5 @@ VEHICLE_MAP_PORT=20800
|
||||
OPEN_PLATFORM_BASE_URL=https://open.d.lnoneos.com
|
||||
OPEN_PLATFORM_APP_KEY=replace-with-32-character-app-key
|
||||
UPSTREAM_TIMEOUT_SECONDS=15
|
||||
DASHBOARD_CACHE_SECONDS=20
|
||||
DASHBOARD_CACHE_SECONDS=5
|
||||
STATION_CACHE_SECONDS=3600
|
||||
|
||||
@@ -30,4 +30,6 @@ python3 server.py
|
||||
本服务提供:
|
||||
|
||||
- `GET /api/health`:进程及配置状态;
|
||||
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存20秒;加氢站缓存1小时。
|
||||
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。
|
||||
|
||||
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
|
||||
|
||||
@@ -33,25 +33,13 @@ const i18n = {
|
||||
}
|
||||
};
|
||||
|
||||
// 车牌首字对应省级行政区。全国视图按车辆注册地聚合,因此离线、无定位车辆也会计入。
|
||||
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]
|
||||
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;
|
||||
@@ -64,12 +52,15 @@ let currentLang = 'zh';
|
||||
let currentRankType = 'fleet';
|
||||
let dashboard = null;
|
||||
let refreshTimer = null;
|
||||
let provinceAreaNodePromise = null;
|
||||
let provinceAggregationVersion = 0;
|
||||
let vehicleProvinceSummary = { nodes: [], unassigned: 0, loading: true };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initClock();
|
||||
initAMapInstance();
|
||||
loadDashboard();
|
||||
refreshTimer = window.setInterval(loadDashboard, 30000);
|
||||
refreshTimer = window.setInterval(loadDashboard, 15000);
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
@@ -80,6 +71,7 @@ async function loadDashboard() {
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
dashboard = payload;
|
||||
updateDashboardUI();
|
||||
refreshVehicleProvinceNodes(payload);
|
||||
setDataState('ready');
|
||||
} catch (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('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} <small>km</small>`;
|
||||
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'
|
||||
? `开放平台已同步 · ${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}`;
|
||||
? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${provinceText}${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned}辆无实时位置` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}`
|
||||
: `Open platform synced · ${summary.totalVehicles} vehicles · ${provinceText}${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned} without realtime location` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`;
|
||||
updateStatusPanel();
|
||||
renderRankingList(currentRankType);
|
||||
renderAMapMarkers();
|
||||
@@ -175,38 +170,123 @@ 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 compactProvinceName(name) {
|
||||
return String(name || '')
|
||||
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
|
||||
.replace(/特别行政区$/, '')
|
||||
.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() {
|
||||
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 };
|
||||
return {
|
||||
...vehicleProvinceSummary,
|
||||
nodes: vehicleProvinceSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
|
||||
};
|
||||
}
|
||||
|
||||
function vehicleNodes() {
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<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 rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="styles.css?v=2026080322">
|
||||
|
||||
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
|
||||
<script type="text/javascript">
|
||||
@@ -188,6 +188,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=2026080322"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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_APP_KEY = os.getenv("OPEN_PLATFORM_APP_KEY", "").strip()
|
||||
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"))
|
||||
|
||||
_cache_lock = threading.Lock()
|
||||
@@ -152,6 +152,8 @@ class VehicleMapHandler(SimpleHTTPRequestHandler):
|
||||
super().do_GET()
|
||||
|
||||
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("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
self.send_header("X-Frame-Options", "SAMEORIGIN")
|
||||
|
||||
+13
-4
@@ -12,13 +12,22 @@ dashboard = {
|
||||
{ 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();
|
||||
assert.equal(summary.nodes.length, 2);
|
||||
assert.equal(summary.unassigned, 1);
|
||||
assert.equal(summary.unassigned, 2);
|
||||
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.dist, 15);
|
||||
assert.equal(guangdong.dist, 10);
|
||||
|
||||
map = { getZoom: () => 4.8 };
|
||||
assert.equal(vehicleNodes().length, 2);
|
||||
@@ -32,7 +41,7 @@ assert.equal(vehicleNodes().reduce((sum, node) => sum + node.count, 0), 2);
|
||||
const sandbox = {
|
||||
assert,
|
||||
console,
|
||||
document: { addEventListener() {} },
|
||||
document: { addEventListener() {}, querySelector() {}, createElement() { return {}; }, head: { appendChild() {} } },
|
||||
window: {},
|
||||
setInterval() {},
|
||||
clearInterval() {}
|
||||
|
||||
Reference in New Issue
Block a user