416 lines
23 KiB
JavaScript
416 lines
23 KiB
JavaScript
/** Lingniu Vehicle Map — live data from the vehicle open platform. */
|
|
|
|
const i18n = {
|
|
zh: {
|
|
vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络',
|
|
kpiTotalFleet: '车辆总数', kpiOnlineFleet: '当前在线运行', kpiDailyMileage: '今日运营里程',
|
|
unitVehicles: '辆', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站',
|
|
themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)',
|
|
legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角',
|
|
btn3dView: '3D/2D 视角', btnNationalView: '全国视图',
|
|
mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
|
|
mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
|
|
panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
|
|
panelRankVehicle: '省级车辆分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
|
|
statusRunning: '运行中', statusStopped: '静止中', statusOffline: '离线',
|
|
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点',
|
|
rankByFleet: '按数量', rankByDist: '按里程', onlineText: '在线', unitTail: '台'
|
|
},
|
|
en: {
|
|
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
|
|
kpiTotalFleet: 'Total Fleet', kpiOnlineFleet: 'Active Online', kpiDailyMileage: 'Today Mileage',
|
|
unitVehicles: 'units', unitStations: 'stations', btnVehicle: 'Fleet', btnStation: 'H₂ Stations',
|
|
themeDark: 'Dark', themeLight: 'Light', legendPrimary: 'Core (>100)',
|
|
legendMid: 'Key (20-100)', legendLow: 'Standard (<20)', btnResetView: 'Reset View',
|
|
btn3dView: '3D/2D View', btnNationalView: 'National View',
|
|
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',
|
|
statusRunning: 'Active', statusStopped: 'Idle', statusOffline: 'Offline',
|
|
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations',
|
|
rankByFleet: 'By Count', rankByDist: 'By Mileage', onlineText: 'Online', unitTail: 'units'
|
|
}
|
|
};
|
|
|
|
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;
|
|
let markerList = [];
|
|
let infoWindowList = [];
|
|
let is3DPitch = true;
|
|
let currentMode = 'vehicle';
|
|
let currentTheme = 'theme-white';
|
|
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, 15000);
|
|
});
|
|
|
|
async function loadDashboard() {
|
|
setDataState('loading');
|
|
try {
|
|
const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } });
|
|
const payload = await response.json();
|
|
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);
|
|
setDataState('error');
|
|
}
|
|
}
|
|
|
|
function setDataState(state) {
|
|
const el = document.getElementById('mapStatusText');
|
|
if (!el) return;
|
|
if (state === 'loading' && !dashboard) el.textContent = currentLang === 'zh' ? '正在同步开放平台数据…' : 'Syncing open-platform data…';
|
|
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
|
|
}
|
|
|
|
function initAMapInstance() {
|
|
if (typeof AMap === 'undefined') {
|
|
setDataState('error');
|
|
return;
|
|
}
|
|
map = new AMap.Map('amapContainer', {
|
|
zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30,
|
|
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
|
|
});
|
|
map.on('complete', renderAMapMarkers);
|
|
map.on('zoomend', renderAMapMarkers);
|
|
}
|
|
|
|
function updateDashboardUI() {
|
|
if (!dashboard) return;
|
|
const dict = i18n[currentLang];
|
|
const summary = dashboard.summary;
|
|
const total = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations;
|
|
const active = currentMode === 'vehicle' ? summary.onlineVehicles : summary.cooperativeStations;
|
|
const unit = currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations;
|
|
const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0';
|
|
document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle;
|
|
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`);
|
|
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}`;
|
|
updateStatusPanel();
|
|
renderRankingList(currentRankType);
|
|
renderAMapMarkers();
|
|
}
|
|
|
|
function updateStatusPanel() {
|
|
const dict = i18n[currentLang];
|
|
const summary = dashboard.summary;
|
|
const title = document.getElementById('panelStatusTitle');
|
|
const labels = [document.getElementById('statusLabel1'), document.getElementById('statusLabel2'), document.getElementById('statusLabel3')];
|
|
const values = [document.getElementById('statusValue1'), document.getElementById('statusValue2'), document.getElementById('statusValue3')];
|
|
let counts;
|
|
if (currentMode === 'vehicle') {
|
|
title.textContent = dict.panelStatusVehicle;
|
|
counts = [summary.drivingVehicles, summary.idleVehicles, summary.offlineVehicles];
|
|
labels[0].textContent = dict.statusRunning; labels[1].textContent = dict.statusStopped; labels[2].textContent = dict.statusOffline;
|
|
} else {
|
|
title.textContent = dict.panelStatusStation;
|
|
counts = [summary.cooperativeStations, summary.totalStations - summary.cooperativeStations, summary.totalStations];
|
|
labels[0].textContent = dict.stationCooperative; labels[1].textContent = dict.stationExternal; labels[2].textContent = dict.stationTotal;
|
|
}
|
|
const denominator = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations;
|
|
values.forEach((element, index) => {
|
|
const pct = denominator ? counts[index] * 100 / denominator : 0;
|
|
element.innerHTML = `${formatNumber(counts[index])} <small>${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}</small>`;
|
|
const pctElement = document.getElementById(`statusPct${index + 1}`);
|
|
if (pctElement) pctElement.textContent = `(${pct.toFixed(1)}%)`;
|
|
});
|
|
const segments = document.querySelectorAll('.liquid-progress-bar .seg');
|
|
if (segments.length === 3) {
|
|
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('、')
|
|
}));
|
|
}
|
|
|
|
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() {
|
|
return {
|
|
...vehicleProvinceSummary,
|
|
nodes: vehicleProvinceSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
|
|
};
|
|
}
|
|
|
|
function vehicleNodes() {
|
|
if ((map?.getZoom?.() || 0) < 7) return buildVehicleProvinceNodes().nodes;
|
|
return buildVehicleClusters();
|
|
}
|
|
|
|
function stationNodes() {
|
|
const stations = dashboard?.stations || [];
|
|
if ((map?.getZoom?.() || 0) >= 7) {
|
|
return stations.map(station => ({
|
|
id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude],
|
|
count: 1, online: station.cooperative ? 1 : 0,
|
|
detail: [station.province, station.city, station.district, station.address].filter(Boolean).join(' '), cooperative: station.cooperative
|
|
}));
|
|
}
|
|
const grouped = new Map();
|
|
for (const station of stations) {
|
|
const province = station.province || (currentLang === 'zh' ? '未标注区域' : 'Unspecified');
|
|
if (!grouped.has(province)) grouped.set(province, { kind: 'stationCluster', name: province, lng: 0, lat: 0, count: 0, online: 0, stations: [] });
|
|
const group = grouped.get(province);
|
|
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1;
|
|
group.online += station.cooperative ? 1 : 0; group.stations.push(station);
|
|
}
|
|
return [...grouped.values()].map((group, index) => ({
|
|
...group, id: `station-cluster-${index}`, lnglat: [group.lng / group.count, group.lat / group.count],
|
|
detail: group.stations.slice(0, 5).map(station => station.name).join('、')
|
|
}));
|
|
}
|
|
|
|
function renderAMapMarkers() {
|
|
if (!map || !dashboard) return;
|
|
markerList.forEach(marker => marker.remove()); markerList = [];
|
|
infoWindowList.forEach(infoWindow => infoWindow.close()); infoWindowList = [];
|
|
const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes();
|
|
const dict = i18n[currentLang];
|
|
for (const node of nodes) {
|
|
const isVehicleNode = node.kind === 'vehicleProvince' || node.kind === 'vehicleCluster';
|
|
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 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,
|
|
content: `<div class="map-info-window"><strong>${escapeHTML(node.name)}</strong><div>${escapeHTML(node.detail || '')}</div>${isVehicleNode ? `<div>${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km</div>` : ''}</div>`,
|
|
offset: new AMap.Pixel(0, -32)
|
|
});
|
|
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 === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
|
|
marker.setMap(map); markerList.push(marker);
|
|
}
|
|
}
|
|
|
|
function renderRankingList(type) {
|
|
const box = document.getElementById('rankingListBox');
|
|
if (!box || !dashboard) return;
|
|
box.innerHTML = '';
|
|
let rows;
|
|
if (currentMode === 'vehicle') {
|
|
rows = buildVehicleProvinceNodes().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 }));
|
|
} else {
|
|
const provinces = new Map();
|
|
for (const station of dashboard.stations) {
|
|
const name = station.province || (currentLang === 'zh' ? '未标注' : 'Unspecified');
|
|
provinces.set(name, (provinces.get(name) || 0) + 1);
|
|
}
|
|
rows = [...provinces.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([name, value]) => ({ name, value: `${value} ${i18n[currentLang].unitStations}` }));
|
|
}
|
|
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);
|
|
box.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function switchRankTab(type) {
|
|
currentRankType = type;
|
|
document.querySelectorAll('.gtab').forEach(button => button.classList.remove('active'));
|
|
if (window.event?.target) window.event.target.classList.add('active');
|
|
renderRankingList(type);
|
|
}
|
|
|
|
function switchMode(mode) {
|
|
currentMode = mode;
|
|
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
|
|
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
|
|
updateDashboardUI();
|
|
}
|
|
|
|
function setLanguage(lang) {
|
|
currentLang = lang;
|
|
document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active'));
|
|
document.querySelector(`.lang-${lang}-btn`)?.classList.add('active');
|
|
const dict = i18n[lang];
|
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
|
const key = element.getAttribute('data-i18n'); if (dict[key]) element.textContent = dict[key];
|
|
});
|
|
updateDashboardUI();
|
|
}
|
|
|
|
function setTheme(themeName) {
|
|
currentTheme = themeName; document.body.className = themeName;
|
|
document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active'));
|
|
document.querySelector(themeName === 'theme-dark' ? '.theme-dark-btn' : '.theme-white-btn')?.classList.add('active');
|
|
if (map) { map.setMapStyle(getAMapThemeStyle(themeName)); renderAMapMarkers(); }
|
|
}
|
|
|
|
function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; }
|
|
function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } }
|
|
function clearProvinceFilter() { resetMapView(); }
|
|
function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } }
|
|
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
|
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
|
function initClock() {
|
|
const clock = document.getElementById('clockTime');
|
|
const update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); };
|
|
update(); window.setInterval(update, 1000);
|
|
}
|