feat(map): add province city district vehicle drilldown
This commit is contained in:
@@ -33,3 +33,5 @@ python3 server.py
|
||||
- `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。
|
||||
|
||||
全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。
|
||||
|
||||
车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。
|
||||
|
||||
@@ -11,7 +11,7 @@ const i18n = {
|
||||
mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
|
||||
mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
|
||||
panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
|
||||
panelRankVehicle: '省级车辆分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
|
||||
panelRankVehicle: '车辆区域分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
|
||||
statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线',
|
||||
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点',
|
||||
rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台'
|
||||
@@ -26,7 +26,7 @@ const i18n = {
|
||||
mapFooterGis: 'Engine: Lingniu H₂ GIS (AMap 3D Engine)',
|
||||
mapFooterStatus: 'Source: Lingniu Vehicle Open Platform', hintNational: 'View: National Overview',
|
||||
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',
|
||||
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations',
|
||||
rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units'
|
||||
@@ -52,9 +52,13 @@ 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 };
|
||||
let districtExplorerPromise = null;
|
||||
const areaNodePromises = new Map();
|
||||
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', () => {
|
||||
initClock();
|
||||
@@ -71,7 +75,7 @@ async function loadDashboard() {
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
dashboard = payload;
|
||||
updateDashboardUI();
|
||||
refreshVehicleProvinceNodes(payload);
|
||||
refreshVehicleRegionNodes(payload);
|
||||
setDataState('ready');
|
||||
} catch (error) {
|
||||
console.error('dashboard refresh failed', error);
|
||||
@@ -96,7 +100,13 @@ function initAMapInstance() {
|
||||
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
|
||||
});
|
||||
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() {
|
||||
@@ -111,13 +121,11 @@ function updateDashboardUI() {
|
||||
document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`;
|
||||
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`);
|
||||
const regionSummary = buildVehicleRegionNodes();
|
||||
const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level) : regionSummaryText(regionSummary);
|
||||
document.getElementById('mapStatusText').textContent = currentLang === 'zh'
|
||||
? `开放平台已同步 · ${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}`;
|
||||
? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned}辆无实时位置` : ''} · ${summary.totalStations}座加氢站 · ${dashboard.asOf}`
|
||||
: `Open platform synced · ${summary.totalVehicles} vehicles · ${regionText}${regionSummary.unassigned ? ` · ${regionSummary.unassigned} without realtime location` : ''} · ${summary.totalStations} stations · ${dashboard.asOf}`;
|
||||
updateStatusPanel();
|
||||
renderRankingList(currentRankType);
|
||||
renderAMapMarkers();
|
||||
@@ -151,23 +159,7 @@ function updateStatusPanel() {
|
||||
const segmentCounts = currentMode === 'vehicle' ? counts : [counts[0], counts[1], 0];
|
||||
segments.forEach((segment, index) => segment.style.width = `${denominator ? segmentCounts[index] * 100 / denominator : 0}%`);
|
||||
}
|
||||
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? dict.panelRankVehicle : 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('、')
|
||||
}));
|
||||
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation;
|
||||
}
|
||||
|
||||
function compactProvinceName(name) {
|
||||
@@ -177,6 +169,38 @@ function compactProvinceName(name) {
|
||||
.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() {
|
||||
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
|
||||
if (window.AMapUI) return Promise.resolve();
|
||||
@@ -194,21 +218,33 @@ function loadAMapUIScript() {
|
||||
});
|
||||
}
|
||||
|
||||
function chinaProvinceAreaNode() {
|
||||
if (provinceAreaNodePromise) return provinceAreaNodePromise;
|
||||
provinceAreaNodePromise = loadAMapUIScript().then(() => new Promise((resolve, reject) => {
|
||||
function districtExplorer() {
|
||||
if (districtExplorerPromise) return districtExplorerPromise;
|
||||
districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => {
|
||||
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);
|
||||
});
|
||||
resolve(new DistrictExplorer({ eventSupport: false }));
|
||||
});
|
||||
})).catch(error => {
|
||||
provinceAreaNodePromise = null;
|
||||
districtExplorerPromise = null;
|
||||
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;
|
||||
@@ -241,58 +277,140 @@ function wgs84ToGcj02(longitude, latitude) {
|
||||
];
|
||||
}
|
||||
|
||||
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) {
|
||||
function locatedVehicles(vehicles = dashboard?.vehicles || []) {
|
||||
return vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude)));
|
||||
}
|
||||
|
||||
function partitionVehiclesByArea(areaNode, vehicles, level) {
|
||||
const groups = [];
|
||||
const unmatched = [];
|
||||
for (const group of areaNode.groupByPosition(vehicles, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)))) {
|
||||
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);
|
||||
if (group.subFeatureIndex < 0 || !feature) { unmatched.push(...group.points); continue; }
|
||||
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 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('、')
|
||||
});
|
||||
groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points });
|
||||
}
|
||||
return { nodes, unassigned, loading: false };
|
||||
return { groups, unmatched };
|
||||
}
|
||||
|
||||
async function refreshVehicleProvinceNodes(sourceDashboard = dashboard) {
|
||||
const version = ++provinceAggregationVersion;
|
||||
function fallbackRegionGroup(parent, vehicles) {
|
||||
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 chinaProvinceAreaNode();
|
||||
if (version !== provinceAggregationVersion || sourceDashboard !== dashboard) return;
|
||||
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode, sourceDashboard?.vehicles || []);
|
||||
updateDashboardUI();
|
||||
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.error('province aggregation failed', error);
|
||||
if (version !== provinceAggregationVersion) return;
|
||||
vehicleProvinceSummary = { nodes: [], unassigned: sourceDashboard?.vehicles?.length || 0, loading: false };
|
||||
updateDashboardUI();
|
||||
console.warn(`region expansion failed for ${parent.adcode}`, error);
|
||||
return [parent];
|
||||
}
|
||||
}));
|
||||
return expanded.flat();
|
||||
}
|
||||
|
||||
function buildVehicleProvinceNodes() {
|
||||
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 {
|
||||
const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []);
|
||||
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return;
|
||||
result.nodes = nodesInCurrentView(result.nodes, level);
|
||||
vehicleRegionSummary = result;
|
||||
} catch (error) {
|
||||
console.error('vehicle region aggregation failed', error);
|
||||
if (version !== regionAggregationVersion) return;
|
||||
vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false };
|
||||
}
|
||||
updateDashboardUI();
|
||||
}
|
||||
|
||||
function buildVehicleRegionNodes() {
|
||||
return {
|
||||
...vehicleProvinceSummary,
|
||||
nodes: vehicleProvinceSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
|
||||
...vehicleRegionSummary,
|
||||
nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
|
||||
};
|
||||
}
|
||||
|
||||
function vehicleNodes() {
|
||||
if ((map?.getZoom?.() || 0) < 7) return buildVehicleProvinceNodes().nodes;
|
||||
return buildVehicleClusters();
|
||||
}
|
||||
function vehicleNodes() { return buildVehicleRegionNodes().nodes; }
|
||||
|
||||
function stationNodes() {
|
||||
const stations = dashboard?.stations || [];
|
||||
@@ -324,11 +442,15 @@ function renderAMapMarkers() {
|
||||
const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes();
|
||||
const dict = i18n[currentLang];
|
||||
for (const node of nodes) {
|
||||
const isVehicleNode = node.kind === 'vehicleProvince' || node.kind === 'vehicleCluster';
|
||||
const isVehicleNode = node.kind.startsWith('vehicle');
|
||||
const markerContent = document.createElement('div');
|
||||
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}`;
|
||||
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>`;
|
||||
const label = node.kind === 'station' ? (node.cooperative ? 'H₂ · 合作' : 'H₂')
|
||||
: 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 infoWindow = new AMap.InfoWindow({
|
||||
isCustom: true,
|
||||
@@ -338,7 +460,9 @@ function renderAMapMarkers() {
|
||||
infoWindowList.push(infoWindow);
|
||||
marker.on('mouseover', () => infoWindow.open(map, node.lnglat));
|
||||
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));
|
||||
marker.setMap(map); markerList.push(marker);
|
||||
}
|
||||
@@ -350,10 +474,15 @@ function renderRankingList(type) {
|
||||
box.innerHTML = '';
|
||||
let rows;
|
||||
if (currentMode === 'vehicle') {
|
||||
rows = buildVehicleProvinceNodes().nodes
|
||||
rows = buildVehicleRegionNodes().nodes
|
||||
.sort((a, b) => type === 'dist' ? b.dist - a.dist : b.count - a.count)
|
||||
.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 {
|
||||
const provinces = new Map();
|
||||
for (const station of dashboard.stations) {
|
||||
@@ -365,7 +494,7 @@ function renderRankingList(type) {
|
||||
rows.forEach((item, index) => {
|
||||
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>`;
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -382,6 +511,7 @@ function switchMode(mode) {
|
||||
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
|
||||
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
|
||||
updateDashboardUI();
|
||||
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
|
||||
+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?v=2026080322">
|
||||
<link rel="stylesheet" href="styles.css?v=2026080324">
|
||||
|
||||
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
|
||||
<script type="text/javascript">
|
||||
@@ -188,6 +188,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=2026080322"></script>
|
||||
<script src="app.js?v=2026080324"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -637,6 +637,15 @@ body {
|
||||
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 {
|
||||
transform: scale(1.15);
|
||||
border-color: var(--accent-cyan) !important;
|
||||
|
||||
+33
-9
@@ -20,22 +20,46 @@ const areaNode = {
|
||||
];
|
||||
}
|
||||
};
|
||||
vehicleProvinceSummary = aggregateVehicleProvinces(areaNode);
|
||||
const summary = buildVehicleProvinceNodes();
|
||||
assert.equal(summary.nodes.length, 2);
|
||||
assert.equal(summary.unassigned, 2);
|
||||
const guangdong = summary.nodes.find(node => node.nameZh === '广东');
|
||||
const partition = partitionVehiclesByArea(areaNode, locatedVehicles(), 'province');
|
||||
assert.equal(partition.groups.length, 2);
|
||||
assert.equal(partition.unmatched.length, 0);
|
||||
const provinceNodes = regionNodesFromGroups(partition.groups, 'province');
|
||||
assert.equal(provinceNodes.length, 2);
|
||||
const guangdong = provinceNodes.find(node => node.nameZh === '广东');
|
||||
assert.equal(guangdong.count, 1);
|
||||
assert.equal(guangdong.online, 1);
|
||||
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 };
|
||||
assert.equal(vehicleNodes().length, 2);
|
||||
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleProvince'));
|
||||
|
||||
map = { getZoom: () => 8 };
|
||||
assert.ok(vehicleNodes().every(node => node.kind === 'vehicleCluster'));
|
||||
assert.equal(vehicleNodes().reduce((sum, node) => sum + node.count, 0), 2);
|
||||
const cityPartition = partitionVehiclesByArea({
|
||||
groupByPosition(points) {
|
||||
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 = {
|
||||
@@ -47,4 +71,4 @@ const sandbox = {
|
||||
clearInterval() {}
|
||||
};
|
||||
vm.runInNewContext(`${source}\n${checks}`, sandbox, { filename: 'app.js' });
|
||||
console.log('vehicle province aggregation tests: ok');
|
||||
console.log('vehicle hierarchy aggregation tests: ok');
|
||||
|
||||
Reference in New Issue
Block a user