From dc5f88cf16b54d281dbe7ea686769a6248b86065 Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 3 Aug 2026 21:49:42 +0800 Subject: [PATCH] fix(map): group national view by live GPS province --- .env.example | 2 +- README.md | 4 +- app.js | 182 ++++++++++++++++++++++++++++++++------------- index.html | 4 +- server.py | 4 +- tests/test_app.mjs | 17 ++++- 6 files changed, 153 insertions(+), 60 deletions(-) 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 @@ - + + diff --git a/server.py b/server.py index 949fdcc..ccca8c3 100644 --- a/server.py +++ b/server.py @@ -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") diff --git a/tests/test_app.mjs b/tests/test_app.mjs index 34af520..60029a9 100644 --- a/tests/test_app.mjs +++ b/tests/test_app.mjs @@ -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() {}