diff --git a/.env.example b/.env.example
index b64074b..4969dfa 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/README.md b/README.md
index f17afb2..e591d04 100644
--- a/README.md
+++ b/README.md
@@ -30,4 +30,6 @@ python3 server.py
本服务提供:
- `GET /api/health`:进程及配置状态;
-- `GET /api/dashboard`:面向前端的聚合数据,默认缓存20秒;加氢站缓存1小时。
+- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。
+
+全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
diff --git a/app.js b/app.js
index bb7d4c4..59be4e2 100644
--- a/app.js
+++ b/app.js
@@ -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)} ${unit} (${activeRate}%)`;
document.getElementById('kpiDailyDist').innerHTML = `${formatNumber(summary.todayMileageKm, 1)} km`;
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() {
diff --git a/index.html b/index.html
index aafcd60..728ca6d 100644
--- a/index.html
+++ b/index.html
@@ -8,7 +8,7 @@
-
+
+