feat(map): add province city district vehicle drilldown

This commit is contained in:
lingniu
2026-08-03 22:03:38 +08:00
parent dc5f88cf16
commit b6f1e89e4f
5 changed files with 256 additions and 91 deletions
+2
View File
@@ -33,3 +33,5 @@ python3 server.py
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。 - `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
+210 -80
View File
@@ -11,7 +11,7 @@ const i18n = {
mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)', mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势', mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布', panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
panelRankVehicle: '省级车辆分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名', panelRankVehicle: '车辆区域分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线', statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线',
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点',
rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台' rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台'
@@ -26,7 +26,7 @@ const i18n = {
mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)', mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)',
mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview', mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview',
panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown', panelStatusVehicle: 'Fleet Status Breakdown', panelStatusStation: 'Station Type Breakdown',
panelRankVehicle: 'Top Provinces by Fleet', panelRankStation: 'Top Provinces by Stations', panelRankVehicle: 'Top Fleet Regions', panelRankStation: 'Top Provinces by Stations',
statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline', statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline',
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations', stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations',
rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units' rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units'
@@ -52,9 +52,13 @@ 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 districtExplorerPromise = null;
let provinceAggregationVersion = 0; const areaNodePromises = new Map();
let vehicleProvinceSummary = { nodes: [], unassigned: 0, loading: true }; let regionAggregationVersion = 0;
let vehicleRegionSummary = { level: 'province', nodes: [], unassigned: 0, loading: true };
const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 };
const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initClock(); initClock();
@@ -71,7 +75,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); refreshVehicleRegionNodes(payload);
setDataState('ready'); setDataState('ready');
} catch (error) { } catch (error) {
console.error('dashboard refresh failed', error); console.error('dashboard refresh failed', error);
@@ -96,7 +100,13 @@ function initAMapInstance() {
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
}); });
map.on('complete', renderAMapMarkers); map.on('complete', renderAMapMarkers);
map.on('zoomend', renderAMapMarkers); map.on('zoomend', () => {
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else { renderRankingList(currentRankType); renderAMapMarkers(); }
});
map.on('moveend', () => {
if (currentMode === 'vehicle' && hierarchyLevelForZoom() !== 'province') refreshVehicleRegionNodes(dashboard);
});
} }
function updateDashboardUI() { function updateDashboardUI() {
@@ -111,13 +121,11 @@ function updateDashboardUI() {
document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`; document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`;
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 regionSummary = buildVehicleRegionNodes();
const provinceText = provinceSummary.loading const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level) : regionSummaryText(regionSummary);
? (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}辆授权车辆 · ${provinceText}${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned}辆无实时位置` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}` ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned}辆无实时位置` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}`
: `Open platform synced · ${summary.totalVehicles} vehicles · ${provinceText}${provinceSummary.unassigned ? ` · ${provinceSummary.unassigned} without realtime location` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`; : `Open platform synced · ${summary.totalVehicles} vehicles · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned} without realtime location` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`;
updateStatusPanel(); updateStatusPanel();
renderRankingList(currentRankType); renderRankingList(currentRankType);
renderAMapMarkers(); renderAMapMarkers();
@@ -151,23 +159,7 @@ function updateStatusPanel() {
const segmentCounts = currentMode === 'vehicle' ? counts : [counts[0], counts[1], 0]; const segmentCounts = currentMode === 'vehicle' ? counts : [counts[0], counts[1], 0];
segments.forEach((segment, index) => segment.style.width = `${denominator ? segmentCounts[index] * 100 / denominator : 0}%`); segments.forEach((segment, index) => segment.style.width = `${denominator ? segmentCounts[index] * 100 / denominator : 0}%`);
} }
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? dict.panelRankVehicle : dict.panelRankStation; document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation;
}
function buildVehicleClusters() {
const clusters = new Map();
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: '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);
}
return [...clusters.values()].map((cluster, index) => ({
...cluster, id: `vehicle-${index}`, lnglat: [cluster.lng / cluster.count, cluster.lat / cluster.count],
name: currentLang === 'zh' ? '车辆集群' : 'Vehicle cluster', detail: cluster.vehicles.slice(0, 5).map(v => v.plateNumber || v.vin).join('、')
}));
} }
function compactProvinceName(name) { function compactProvinceName(name) {
@@ -177,6 +169,38 @@ function compactProvinceName(name) {
.replace(/[省市]$/, ''); .replace(/[省市]$/, '');
} }
function compactRegionName(name) {
return compactProvinceName(name)
.replace(/自治州$/, '')
.replace(/地区$/, '')
.replace(/林区$/, '')
.replace(/[盟区县市]$/, '');
}
function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
if (zoom < REGION_ZOOM.city) return 'province';
if (zoom < REGION_ZOOM.district) return 'city';
if (zoom < REGION_ZOOM.vehicle) return 'district';
return 'vehicle';
}
function regionLoadingText(level) {
const zh = { province: '省级归属计算中', city: '市级归属计算中', district: '区县级归属计算中', vehicle: '单车定位计算中' };
const en = { province: 'province grouping in progress', city: 'city grouping in progress', district: 'district grouping in progress', vehicle: 'vehicle positioning in progress' };
return (currentLang === 'zh' ? zh : en)[level];
}
function regionSummaryText(summary) {
const zh = { province: '个省级行政区', city: '个当前视野城市', district: '个当前视野区县', vehicle: '辆当前视野车辆' };
const en = { province: ' province-level regions', city: ' cities in view', district: ' districts in view', vehicle: ' vehicles in view' };
return `${summary.nodes.length}${(currentLang === 'zh' ? zh : en)[summary.level]}`;
}
function vehicleRankTitle(level) {
if (currentLang === 'en') return ({ province: 'Top Provinces by Fleet', city: 'Top Cities by Fleet', district: 'Top Districts by Fleet', vehicle: 'Vehicles in Current View' })[level];
return ({ province: '省级车辆分布 TOP 排名', city: '市级车辆分布 TOP 排名', district: '区县级车辆分布 TOP 排名', vehicle: '当前视野车辆' })[level];
}
function loadAMapUIScript() { function loadAMapUIScript() {
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1'; const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
if (window.AMapUI) return Promise.resolve(); if (window.AMapUI) return Promise.resolve();
@@ -194,21 +218,33 @@ function loadAMapUIScript() {
}); });
} }
function chinaProvinceAreaNode() { function districtExplorer() {
if (provinceAreaNodePromise) return provinceAreaNodePromise; if (districtExplorerPromise) return districtExplorerPromise;
provinceAreaNodePromise = loadAMapUIScript().then(() => new Promise((resolve, reject) => { districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => {
window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => { window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => {
const explorer = new DistrictExplorer({ eventSupport: false }); resolve(new DistrictExplorer({ eventSupport: false }));
explorer.loadAreaNode(100000, (error, areaNode) => {
if (error || !areaNode) reject(error || new Error('全国省级边界不可用'));
else resolve(areaNode);
});
}); });
})).catch(error => { })).catch(error => {
provinceAreaNodePromise = null; districtExplorerPromise = null;
throw error; throw error;
}); });
return provinceAreaNodePromise; return districtExplorerPromise;
}
function areaNodeFor(adcode) {
const key = String(adcode);
if (areaNodePromises.has(key)) return areaNodePromises.get(key);
const promise = districtExplorer().then(explorer => new Promise((resolve, reject) => {
explorer.loadAreaNode(Number(adcode), (error, areaNode) => {
if (error || !areaNode) reject(error || new Error(`行政区边界不可用: ${adcode}`));
else resolve(areaNode);
});
})).catch(error => {
areaNodePromises.delete(key);
throw error;
});
areaNodePromises.set(key, promise);
return promise;
} }
const GCJ_A = 6378245; const GCJ_A = 6378245;
@@ -241,58 +277,140 @@ function wgs84ToGcj02(longitude, latitude) {
]; ];
} }
function aggregateVehicleProvinces(areaNode, vehicles = dashboard?.vehicles || []) { function locatedVehicles(vehicles = dashboard?.vehicles || []) {
const located = vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude))); return 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))); function partitionVehiclesByArea(areaNode, vehicles, level) {
for (const group of groups) { const groups = [];
const unmatched = [];
for (const group of areaNode.groupByPosition(vehicles, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)))) {
if (!group.points?.length) continue; if (!group.points?.length) continue;
const feature = group.subFeature; const feature = group.subFeature;
if (group.subFeatureIndex < 0 || !feature) { unassigned += group.points.length; continue; } if (group.subFeatureIndex < 0 || !feature) { unmatched.push(...group.points); continue; }
const nameZh = compactProvinceName(feature.properties.name); const rawName = String(feature.properties.name || '');
const nameZh = level === 'province' ? compactProvinceName(rawName) : compactRegionName(rawName);
const positions = group.points.map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude))); const positions = group.points.map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
const center = feature.properties.center || [ const center = feature.properties.center || [
positions.reduce((sum, point) => sum + point[0], 0) / positions.length, positions.reduce((sum, point) => sum + point[0], 0) / positions.length,
positions.reduce((sum, point) => sum + point[1], 0) / positions.length positions.reduce((sum, point) => sum + point[1], 0) / positions.length
]; ];
nodes.push({ groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points });
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 }; return { groups, unmatched };
} }
async function refreshVehicleProvinceNodes(sourceDashboard = dashboard) { function fallbackRegionGroup(parent, vehicles) {
const version = ++provinceAggregationVersion; return { ...parent, vehicles };
}
async function expandRegionGroups(parents, level) {
const expanded = await Promise.all(parents.map(async parent => {
if (level === 'city' && MUNICIPALITIES.has(compactProvinceName(parent.rawName))) {
return [{ ...parent, nameZh: compactProvinceName(parent.rawName) }];
}
try {
const areaNode = await areaNodeFor(parent.adcode);
const partition = partitionVehiclesByArea(areaNode, parent.vehicles, level);
if (partition.unmatched.length) partition.groups.push(fallbackRegionGroup(parent, partition.unmatched));
return partition.groups.length ? partition.groups : [parent];
} catch (error) {
console.warn(`region expansion failed for ${parent.adcode}`, error);
return [parent];
}
}));
return expanded.flat();
}
function regionNodesFromGroups(groups, level) {
return groups.map(group => ({
id: `vehicle-${level}-${group.adcode}`, kind: `vehicle${level.charAt(0).toUpperCase()}${level.slice(1)}`,
level, adcode: group.adcode, nameZh: group.nameZh,
nameEn: level === 'province' ? (PROVINCE_ENGLISH[group.nameZh] || group.nameZh) : group.nameZh,
lnglat: group.lnglat, count: group.vehicles.length, online: group.vehicles.filter(vehicle => vehicle.online).length,
dist: group.vehicles.reduce((sum, vehicle) => sum + Number(vehicle.dailyMileageKm || 0), 0), vehicles: group.vehicles,
detail: group.vehicles.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、')
})).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN'));
}
async function aggregateVehicleHierarchy(level, vehicles = dashboard?.vehicles || []) {
const located = locatedVehicles(vehicles);
let unassigned = vehicles.length - located.length;
const nationalNode = await areaNodeFor(100000);
const provincePartition = partitionVehiclesByArea(nationalNode, located, 'province');
unassigned += provincePartition.unmatched.length;
if (level === 'province') return { level, nodes: regionNodesFromGroups(provincePartition.groups, level), unassigned, loading: false };
const cityGroups = await expandRegionGroups(provincePartition.groups, 'city');
if (level === 'city') return { level, nodes: regionNodesFromGroups(cityGroups, level), unassigned, loading: false };
const districtGroups = await expandRegionGroups(cityGroups, 'district');
return { level, nodes: regionNodesFromGroups(districtGroups, level), unassigned, loading: false };
}
function currentMapBounds() {
const bounds = map?.getBounds?.();
const southWest = bounds?.getSouthWest?.();
const northEast = bounds?.getNorthEast?.();
const west = southWest?.getLng?.(); const south = southWest?.getLat?.();
const east = northEast?.getLng?.(); const north = northEast?.getLat?.();
if (![west, south, east, north].every(Number.isFinite)) return null;
return { west, south, east, north };
}
function vehiclePointNodes(vehicles = dashboard?.vehicles || []) {
const bounds = currentMapBounds();
return locatedVehicles(vehicles).map(vehicle => {
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
return { vehicle, lnglat };
}).filter(({ lnglat }) => !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north))
.map(({ vehicle, lnglat }) => ({
id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle',
nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin,
lnglat, count: 1, online: vehicle.online ? 1 : 0, dist: Number(vehicle.dailyMileageKm || 0), vehicles: [vehicle],
speedKmh: Number(vehicle.speedKmh || 0), protocol: vehicle.protocol || '',
detail: [vehicle.vin, vehicle.protocol, vehicle.recordTime].filter(Boolean).join(' · ')
}));
}
function nodesInCurrentView(nodes, level) {
if (level === 'province') return nodes;
const bounds = currentMapBounds();
if (!bounds) return nodes;
return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north);
}
async function refreshVehicleRegionNodes(sourceDashboard = dashboard) {
if (!sourceDashboard) return;
const level = hierarchyLevelForZoom();
const version = ++regionAggregationVersion;
const unassigned = (sourceDashboard.vehicles || []).length - locatedVehicles(sourceDashboard.vehicles || []).length;
if (level === 'vehicle') {
vehicleRegionSummary = { level, nodes: vehiclePointNodes(sourceDashboard.vehicles || []), unassigned, loading: false };
updateDashboardUI();
return;
}
vehicleRegionSummary = { level, nodes: [], unassigned, loading: true };
updateDashboardUI();
try { try {
const areaNode = await chinaProvinceAreaNode(); const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []);
if (version !== provinceAggregationVersion || sourceDashboard !== dashboard) return; if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return;
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode, sourceDashboard?.vehicles || []); result.nodes = nodesInCurrentView(result.nodes, level);
updateDashboardUI(); vehicleRegionSummary = result;
} catch (error) { } catch (error) {
console.error('province aggregation failed', error); console.error('vehicle region aggregation failed', error);
if (version !== provinceAggregationVersion) return; if (version !== regionAggregationVersion) return;
vehicleProvinceSummary = { nodes: [], unassigned: sourceDashboard?.vehicles?.length || 0, loading: false }; vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false };
updateDashboardUI();
} }
updateDashboardUI();
} }
function buildVehicleProvinceNodes() { function buildVehicleRegionNodes() {
return { return {
...vehicleProvinceSummary, ...vehicleRegionSummary,
nodes: vehicleProvinceSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn })) nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
}; };
} }
function vehicleNodes() { function vehicleNodes() { return buildVehicleRegionNodes().nodes; }
if ((map?.getZoom?.() || 0) < 7) return buildVehicleProvinceNodes().nodes;
return buildVehicleClusters();
}
function stationNodes() { function stationNodes() {
const stations = dashboard?.stations || []; const stations = dashboard?.stations || [];
@@ -324,11 +442,15 @@ function renderAMapMarkers() {
const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes(); const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes();
const dict = i18n[currentLang]; const dict = i18n[currentLang];
for (const node of nodes) { for (const node of nodes) {
const isVehicleNode = node.kind === 'vehicleProvince' || node.kind === 'vehicleCluster'; const isVehicleNode = node.kind.startsWith('vehicle');
const markerContent = document.createElement('div'); const markerContent = document.createElement('div');
markerContent.className = 'province-info-badge-wrap'; markerContent.className = 'province-info-badge-wrap';
const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂') : node.kind === 'stationCluster' ? `${node.count}` : node.kind === 'vehicleProvince' ? `${node.count} ${dict.unitVehicles}` : `${node.count}`; const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂')
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>`; : node.kind === 'stationCluster' ? `${node.count}`
: node.kind === 'vehiclePoint' ? (node.online ? dict.onlineText : dict.statusOffline)
: `${node.count} ${dict.unitVehicles}`;
const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : `${dict.onlineText} ${node.online}`;
markerContent.innerHTML = `<div class="province-info-card${node.kind === 'vehiclePoint' ? ` is-vehicle-point ${node.online ? 'is-online' : 'is-offline'}` : ''}"><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">${escapeHTML(subline)}</div>` : ''}</div>`;
const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name }); const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name });
const infoWindow = new AMap.InfoWindow({ const infoWindow = new AMap.InfoWindow({
isCustom: true, isCustom: true,
@@ -338,7 +460,9 @@ function renderAMapMarkers() {
infoWindowList.push(infoWindow); infoWindowList.push(infoWindow);
marker.on('mouseover', () => infoWindow.open(map, node.lnglat)); marker.on('mouseover', () => infoWindow.open(map, node.lnglat));
marker.on('mouseout', () => infoWindow.close()); marker.on('mouseout', () => infoWindow.close());
if (node.kind === 'vehicleProvince') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat)); if (node.kind === 'vehicleProvince') marker.on('click', () => map.setZoomAndCenter(7.4, node.lnglat));
if (node.kind === 'vehicleCity') marker.on('click', () => map.setZoomAndCenter(9.8, node.lnglat));
if (node.kind === 'vehicleDistrict') marker.on('click', () => map.setZoomAndCenter(12.2, node.lnglat));
if (node.kind === 'stationCluster') 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); marker.setMap(map); markerList.push(marker);
} }
@@ -350,10 +474,15 @@ function renderRankingList(type) {
box.innerHTML = ''; box.innerHTML = '';
let rows; let rows;
if (currentMode === 'vehicle') { if (currentMode === 'vehicle') {
rows = buildVehicleProvinceNodes().nodes rows = buildVehicleRegionNodes().nodes
.sort((a, b) => type === 'dist' ? b.dist - a.dist : b.count - a.count) .sort((a, b) => type === 'dist' ? b.dist - a.dist : b.count - a.count)
.slice(0, 12) .slice(0, 12)
.map(item => ({ name: item.name, value: type === 'dist' ? `${formatNumber(item.dist, 1)} km` : `${formatNumber(item.count)} ${i18n[currentLang].unitVehicles}`, location: item.lnglat })); .map(item => ({
name: item.name,
value: type === 'dist' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : `${formatNumber(item.count)} ${i18n[currentLang].unitVehicles}`,
location: item.lnglat,
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14
}));
} else { } else {
const provinces = new Map(); const provinces = new Map();
for (const station of dashboard.stations) { for (const station of dashboard.stations) {
@@ -365,7 +494,7 @@ function renderRankingList(type) {
rows.forEach((item, index) => { rows.forEach((item, index) => {
const row = document.createElement('div'); row.className = 'rank-glass-row'; const row = document.createElement('div'); row.className = 'rank-glass-row';
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`; row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
if (item.location) row.onclick = () => map?.setZoomAndCenter(9, item.location); if (item.location) row.onclick = () => map?.setZoomAndCenter(item.zoom || 9, item.location);
box.appendChild(row); box.appendChild(row);
}); });
} }
@@ -382,6 +511,7 @@ function switchMode(mode) {
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle'); document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station'); document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
updateDashboardUI(); updateDashboardUI();
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
} }
function setLanguage(lang) { function setLanguage(lang) {
+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?v=2026080322"> <link rel="stylesheet" href="styles.css?v=2026080324">
<!-- 高德地图 (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?v=2026080322"></script> <script src="app.js?v=2026080324"></script>
</body> </body>
</html> </html>
+9
View File
@@ -637,6 +637,15 @@ body {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.province-info-card.is-vehicle-point {
border-radius: 10px;
border-color: rgba(2, 132, 199, 0.34);
box-shadow: 0 5px 16px rgba(2, 132, 199, 0.18);
}
.province-info-card.is-vehicle-point.is-online .p-dot { background: #00a86b; }
.province-info-card.is-vehicle-point.is-offline .p-dot { background: #8e8e93; }
.province-info-badge-wrap:hover .province-info-card { .province-info-badge-wrap:hover .province-info-card {
transform: scale(1.15); transform: scale(1.15);
border-color: var(--accent-cyan) !important; border-color: var(--accent-cyan) !important;
+33 -9
View File
@@ -20,22 +20,46 @@ const areaNode = {
]; ];
} }
}; };
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode); const partition = partitionVehiclesByArea(areaNode, locatedVehicles(), 'province');
const summary = buildVehicleProvinceNodes(); assert.equal(partition.groups.length, 2);
assert.equal(summary.nodes.length, 2); assert.equal(partition.unmatched.length, 0);
assert.equal(summary.unassigned, 2); const provinceNodes = regionNodesFromGroups(partition.groups, 'province');
const guangdong = summary.nodes.find(node => node.nameZh === '广东'); assert.equal(provinceNodes.length, 2);
const guangdong = provinceNodes.find(node => node.nameZh === '广东');
assert.equal(guangdong.count, 1); assert.equal(guangdong.count, 1);
assert.equal(guangdong.online, 1); assert.equal(guangdong.online, 1);
assert.equal(guangdong.dist, 10); assert.equal(guangdong.dist, 10);
assert.equal(hierarchyLevelForZoom(4.8), 'province');
assert.equal(hierarchyLevelForZoom(7), 'city');
assert.equal(hierarchyLevelForZoom(9.5), 'district');
assert.equal(hierarchyLevelForZoom(12), 'vehicle');
vehicleRegionSummary = { level: 'province', nodes: provinceNodes, unassigned: 2, loading: false };
map = { getZoom: () => 4.8 }; map = { getZoom: () => 4.8 };
assert.equal(vehicleNodes().length, 2); assert.equal(vehicleNodes().length, 2);
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince')); assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince'));
map = { getZoom: () => 8 }; const cityPartition = partitionVehiclesByArea({
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleCluster')); groupByPosition(points) {
assert.equal(vehicleNodes().reduce((sum, node) => sum + node.count, 0), 2); return [{ subFeatureIndex: 0, subFeature: { properties: { adcode: '440100', name: '广州市', center: [113.3, 23.1] } }, points }];
}
}, [dashboard.vehicles[0]], 'city');
const cityNodes = regionNodesFromGroups(cityPartition.groups, 'city');
assert.equal(cityNodes[0].kind, 'vehicleCity');
assert.equal(cityNodes[0].nameZh, '广州');
map = {
getZoom: () => 12,
getBounds: () => ({
getSouthWest: () => ({ getLng: () => 100, getLat: () => 20 }),
getNorthEast: () => ({ getLng: () => 125, getLat: () => 35 })
})
};
const points = vehiclePointNodes();
assert.equal(points.length, 2);
assert.ok(points.every(node => node.kind === 'vehiclePoint'));
assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
`; `;
const sandbox = { const sandbox = {
@@ -47,4 +71,4 @@ const sandbox = {
clearInterval() {} clearInterval() {}
}; };
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' }); vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
console.log('vehicle province aggregation tests: ok'); console.log('vehicle hierarchy aggregation tests: ok');