diff --git a/README.md b/README.md index e591d04..58fdfa7 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,5 @@ python3 server.py - `GET /api/dashboard`:面向前端的聚合数据,默认缓存5秒;加氢站缓存1小时。 全国视图按车辆最新 GPS 坐标落入省级行政区,不按车牌归属地推断;没有有效实时坐标的车辆会单独计入“无实时位置”,不会伪造省份归属。页面每15秒刷新一次。 + +车辆地图按缩放级别逐级下钻:全国视角(小于7级)按省聚合,7–9.5级按市聚合,9.5–12级按区县聚合,12级及以上显示当前视野内的单车真实位置。点击省、市、区县气泡会自动进入下一级。 diff --git a/app.js b/app.js index 59be4e2..3afb9dd 100644 --- a/app.js +++ b/app.js @@ -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)} ${unit}`; 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`); + 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 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 { - const areaNode = await chinaProvinceAreaNode(); - if (version !== provinceAggregationVersion || sourceDashboard !== dashboard) return; - vehicleProvinceSummary = aggregateVehicleProvinces(areaNode, sourceDashboard?.vehicles || []); - updateDashboardUI(); + 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('province aggregation failed', error); - if (version !== provinceAggregationVersion) return; - vehicleProvinceSummary = { nodes: [], unassigned: sourceDashboard?.vehicles?.length || 0, loading: false }; - updateDashboardUI(); + console.error('vehicle region aggregation failed', error); + if (version !== regionAggregationVersion) return; + vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false }; } + updateDashboardUI(); } -function buildVehicleProvinceNodes() { +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 = `
${escapeHTML(node.name)}${escapeHTML(label)}
${isVehicleNode ? `
${dict.onlineText} ${node.online}
` : ''}
`; + 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 = `
${escapeHTML(node.name)}${escapeHTML(label)}
${isVehicleNode ? `
${escapeHTML(subline)}
` : ''}
`; 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 = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; - 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) { diff --git a/index.html b/index.html index 728ca6d..148dbaf 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@ - + + diff --git a/styles.css b/styles.css index ae696a4..b8a5f7d 100644 --- a/styles.css +++ b/styles.css @@ -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; diff --git a/tests/test_app.mjs b/tests/test_app.mjs index 60029a9..66f91b5 100644 --- a/tests/test_app.mjs +++ b/tests/test_app.mjs @@ -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');