Files
lingniu-vehicle-ingest/vehicle-map/app.js
T

764 lines
40 KiB
JavaScript

/** Lingniu Vehicle Map — live data from the vehicle open platform. */
const i18n = {
zh: {
vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络',
kpiTotalFleet: '车辆总数', kpiTotalStation: '站点总数',
kpiOnlineFleet: '今日上线车辆', kpiCooperativeStation: '合作站点', kpiDailyMileage: '今日运营里程', kpiMonthlyHydrogen: '本月加氢量',
unitVehicle: '辆', unitVehicles: '辆', unitStation: '座', 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 排名',
statusTotal: '总数', statusRunning: '行驶中', statusStopped: '静止中', statusOffline: '离线',
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationPartnerMarker: '合作',
rankByFleet: '按数量', rankByDist: '按里程', rankByStations: '按站点数', rankByHydrogen: '按加氢量', rankNoHydrogenData: '当前视野暂无加氢数据',
stationViewportEmpty: '当前视野暂无加氢站 · 可拖动地图或复位视角', viewportNow: '当前视野', viewportAll: '全部',
onlineText: '在线', unitTail: '台'
},
en: {
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
kpiTotalFleet: 'Total Fleet', kpiTotalStation: 'Total Stations',
kpiOnlineFleet: 'Active Today', kpiCooperativeStation: 'Partner Stations', kpiDailyMileage: 'Today Mileage', kpiMonthlyHydrogen: 'Monthly Hydrogen Fill',
unitVehicle: 'unit', unitVehicles: 'units', unitStation: 'station', 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 Fleet Regions', panelRankStation: 'Top Provinces by Stations',
statusTotal: 'Total', statusRunning: 'Driving', statusStopped: 'Idle', statusOffline: 'Offline',
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations', stationPartnerMarker: 'Partner',
rankByFleet: 'By Count', rankByDist: 'By Mileage', rankByStations: 'By Stations', rankByHydrogen: 'By H₂ Volume', rankNoHydrogenData: 'No hydrogen data in this view',
stationViewportEmpty: 'No H₂ stations in this view · Pan the map or reset view', viewportNow: 'In view', viewportAll: 'All',
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'
};
const ADMIN_ENGLISH_OVERRIDES = {
六安: "Lu'an", 台州: 'Taizhou', 重庆: 'Chongqing', 长治: 'Changzhi', 长春: 'Changchun',
长沙: 'Changsha', 厦门: 'Xiamen', 乐山: 'Leshan', 朝阳: 'Chaoyang', 鄂尔多斯: 'Ordos',
乌鲁木齐: 'Urumqi', 喀什: 'Kashgar', 香港: 'Hong Kong', 澳门: 'Macao'
};
const ADMIN_SUFFIX_ENGLISH = [
['特别行政区', 'SAR'], ['自治州', 'Autonomous Prefecture'], ['自治县', 'Autonomous County'],
['新区', 'New Area'], ['地区', 'Prefecture'], ['林区', 'Forestry District'], ['盟', 'League'],
['省', 'Province'], ['市', 'City'], ['区', 'District'], ['县', 'County']
];
const ETHNIC_ENGLISH = {
壮族: 'Zhuang', 回族: 'Hui', 维吾尔: 'Uyghur', 蒙古族: 'Mongol', 藏族: 'Tibetan',
彝族: 'Yi', 苗族: 'Miao', 傣族: 'Dai', 白族: 'Bai', 哈尼族: 'Hani', 哈萨克: 'Kazakh',
朝鲜族: 'Korean', 土家族: 'Tujia', 布依族: 'Bouyei', 侗族: 'Dong'
};
let map = null;
let markerList = [];
let infoWindowList = [];
let is3DPitch = true;
let currentMode = 'vehicle';
let currentTheme = 'theme-white';
let currentLang = 'zh';
let currentRankType = 'primary';
let dashboard = null;
let refreshTimer = null;
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();
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();
refreshVehicleRegionNodes(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', () => {
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
});
map.on('moveend', () => {
if (currentMode === 'vehicle' && hierarchyLevelForZoom() !== 'province') refreshVehicleRegionNodes(dashboard);
if (currentMode === 'station') refreshStationViewport();
});
}
function refreshStationViewport() {
updateRankingControls();
renderRankingList(currentRankType);
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 = countUnit(total, currentMode, dict);
const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0';
const kpiLabels = modeKpiLabels(dict);
document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle;
document.getElementById('kpiTotalLabel').textContent = kpiLabels.total;
document.getElementById('kpiActiveLabel').textContent = kpiLabels.active;
document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`;
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`;
const activity = currentMode === 'vehicle'
? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' }
: { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' };
document.getElementById('kpiActivityLabel').textContent = activity.label;
document.getElementById('kpiDailyDist').innerHTML = activity.value == null
? `— <small>${activity.unit}</small>`
: `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`;
const regionSummary = buildVehicleRegionNodes();
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
updateStatusPanel();
updateRankingControls();
renderRankingList(currentRankType);
renderAMapMarkers();
}
function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode, lang = currentLang) {
if (mode === 'station') {
const cooperative = Number(summary.cooperativeStations || 0);
const external = Math.max(0, Number(summary.totalStations || 0) - cooperative);
return lang === 'zh'
? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点 · ${asOf}`
: `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations · ${asOf}`;
}
const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang);
return lang === 'zh'
? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText} · ${asOf}`
: `Open platform synced · ${summary.totalVehicles} authorized vehicles · ${regionText} · ${asOf}`;
}
function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) {
return mode === 'vehicle'
? { total: dict.kpiTotalFleet, active: dict.kpiOnlineFleet }
: { total: dict.kpiTotalStation, active: dict.kpiCooperativeStation };
}
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')];
const dots = [...document.querySelectorAll('.status-glass-grid3 .status-dot')];
const metrics = statusPanelMetrics(summary, currentMode);
title.textContent = currentMode === 'vehicle' ? dict.panelStatusVehicle : dict.panelStatusStation;
const denominator = metrics.total;
values.forEach((element, index) => {
const pct = denominator ? metrics.counts[index] * 100 / denominator : 0;
element.innerHTML = `${formatNumber(metrics.counts[index])} <small>${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}</small>`;
labels[index].textContent = dict[metrics.labelKeys[index]];
if (dots[index]) dots[index].className = `status-dot ${metrics.dotClasses[index]}`;
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) {
segments.forEach((segment, index) => segment.style.width = `${denominator ? metrics.progressCounts[index] * 100 / denominator : 0}%`);
}
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation;
}
function statusPanelMetrics(summary, mode = currentMode) {
const total = Number(mode === 'vehicle' ? summary.totalVehicles : summary.totalStations) || 0;
if (mode === 'vehicle') {
const driving = Number(summary.drivingVehicles) || 0;
const idle = Number(summary.idleVehicles) || 0;
return {
total, counts: [total, driving, idle],
labelKeys: ['statusTotal', 'statusRunning', 'statusStopped'],
dotClasses: ['dot-total', 'dot-mint', 'dot-blue'],
progressCounts: [driving, idle]
};
}
const cooperative = Number(summary.cooperativeStations) || 0;
const external = Math.max(0, total - cooperative);
return {
total, counts: [total, cooperative, external],
labelKeys: ['stationTotal', 'stationCooperative', 'stationExternal'],
dotClasses: ['dot-total', 'dot-mint', 'dot-blue'],
progressCounts: [cooperative, external]
};
}
function compactProvinceName(name) {
return String(name || '')
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
.replace(/特别行政区$/, '')
.replace(/[省市]$/, '');
}
function compactRegionName(name) {
return compactProvinceName(name)
.replace(/自治州$/, '')
.replace(/地区$/, '')
.replace(/林区$/, '')
.replace(/[盟区县市]$/, '');
}
function romanizeAdministrativeBase(value) {
const normalized = String(value || '').trim();
if (!normalized) return '';
if (ADMIN_ENGLISH_OVERRIDES[normalized]) return ADMIN_ENGLISH_OVERRIDES[normalized];
for (const [ethnicZh, ethnicEn] of Object.entries(ETHNIC_ENGLISH)) {
if (!normalized.endsWith(ethnicZh)) continue;
const prefix = normalized.slice(0, -ethnicZh.length);
const romanizedPrefix = romanizeAdministrativeBase(prefix);
return [romanizedPrefix, ethnicEn].filter(Boolean).join(' ');
}
const converter = globalThis.pinyinPro?.pinyin;
if (typeof converter !== 'function') return normalized;
const pinyin = converter(normalized, { toneType: 'none', separator: ' ' });
const joined = String(pinyin || '').replace(/[^A-Za-z0-9]+/g, '');
return joined ? joined.charAt(0).toUpperCase() + joined.slice(1).toLowerCase() : normalized;
}
function administrativeEnglishName(name, level = '') {
const normalized = String(name || '').trim();
if (!normalized || normalized === '[]') return '';
const compactProvince = compactProvinceName(normalized);
if (PROVINCE_ENGLISH[compactProvince] && (level === 'province' || /省|自治区|特别行政区$/.test(normalized) || MUNICIPALITIES.has(compactProvince))) {
return PROVINCE_ENGLISH[compactProvince];
}
const suffixEntry = ADMIN_SUFFIX_ENGLISH.find(([suffix]) => normalized.endsWith(suffix));
const suffix = suffixEntry?.[0] || '';
const base = suffix ? normalized.slice(0, -suffix.length) : normalized;
const translatedBase = ADMIN_ENGLISH_OVERRIDES[base] || romanizeAdministrativeBase(base);
return [translatedBase, suffixEntry?.[1]].filter(Boolean).join(' ');
}
function stationAdministrativePath(station, lang = currentLang) {
const levels = [
['province', station?.province], ['city', station?.city], ['district', station?.district]
].map(([level, name]) => lang === 'en' ? administrativeEnglishName(name, level) : String(name || '').trim())
.filter(name => name && name !== '[]');
return levels.filter((name, index) => index === 0 || name !== levels[index - 1]).join(' · ');
}
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, lang = currentLang) {
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 (lang === 'zh' ? zh : en)[level];
}
function regionSummaryText(summary, lang = currentLang) {
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}${(lang === '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();
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 districtExplorer() {
if (districtExplorerPromise) return districtExplorerPromise;
districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => {
window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => {
resolve(new DistrictExplorer({ eventSupport: false }));
});
})).catch(error => {
districtExplorerPromise = null;
throw error;
});
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_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 locatedVehicles(vehicles = dashboard?.vehicles || []) {
return vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude)));
}
function vehicleActiveToday(vehicle) {
return typeof vehicle?.activeToday === 'boolean' ? vehicle.activeToday : Boolean(vehicle?.online);
}
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) { 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
];
groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points });
}
return { groups, unmatched };
}
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: administrativeEnglishName(group.rawName || group.nameZh, level),
lnglat: group.lnglat, count: group.vehicles.length, online: group.vehicles.filter(vehicle => vehicleActiveToday(vehicle)).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: vehicleActiveToday(vehicle) ? 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 {
...vehicleRegionSummary,
nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
};
}
function vehicleNodes() { return buildVehicleRegionNodes().nodes; }
function stationHierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
if (zoom < 7) return 'province';
if (zoom < 11) return 'city';
return 'station';
}
function stationGroupName(station, level) {
const raw = level === 'province' ? station.province : station.city;
if (!raw) return currentLang === 'zh' ? '未标注区域' : 'Unspecified';
if (currentLang === 'en') return administrativeEnglishName(raw, level) || raw;
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
}
function buildStationNodes(stations = dashboard?.stations || [], level = stationHierarchyLevelForZoom()) {
if (level === 'station') {
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,
adminPath: stationAdministrativePath(station),
detail: [stationAdministrativePath(station), currentLang === 'zh' ? station.address : ''].filter(Boolean).join(' · '),
cooperative: station.cooperative, hydrogenKg: Number(station.totalHydrogenKg) || 0,
hasHydrogen: station.totalHydrogenKg != null
}));
}
const grouped = new Map();
for (const station of stations) {
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
if (!grouped.has(key)) grouped.set(key, {
kind: level === 'province' ? 'stationProvince' : 'stationCity', name: stationGroupName(station, level),
lng: 0, lat: 0, count: 0, online: 0, hydrogenKg: 0, hasHydrogen: false, stations: []
});
const group = grouped.get(key);
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1;
group.online += station.cooperative ? 1 : 0; group.stations.push(station);
if (station.totalHydrogenKg != null) {
group.hasHydrogen = true;
group.hydrogenKg += Number(station.totalHydrogenKg) || 0;
}
}
return [...grouped.values()].map((group, index) => ({
...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count],
detail: group.stations.slice(0, 5).map(station => station.name).join('、')
}));
}
function nodesInsideCurrentMapBounds(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);
}
function stationNodes(visibleOnly = false) {
const nodes = buildStationNodes();
return visibleOnly ? nodesInsideCurrentMapBounds(nodes) : nodes;
}
function stationViewportSummary() {
const level = stationHierarchyLevelForZoom();
const allNodes = stationNodes(false);
const visibleNodes = nodesInsideCurrentMapBounds(allNodes);
return { level, allNodes, visibleNodes };
}
function countUnit(count, resource, dict = i18n[currentLang]) {
const isEnglish = dict === i18n.en;
if (resource === 'vehicle') return isEnglish && Number(count) === 1 ? dict.unitVehicle : dict.unitVehicles;
return isEnglish && Number(count) === 1 ? dict.unitStation : dict.unitStations;
}
function countLabel(count, resource, dict = i18n[currentLang]) {
return `${formatNumber(count)} ${countUnit(count, resource, dict)}`;
}
function markerLabel(node, dict = i18n[currentLang]) {
if (node.kind === 'station') return node.cooperative ? `H₂ · ${dict.stationPartnerMarker}` : 'H₂';
if (node.kind === 'stationProvince' || node.kind === 'stationCity' || node.kind === 'stationCluster') return countLabel(node.count, 'station', dict);
if (node.kind === 'vehiclePoint') return node.online ? dict.onlineText : dict.statusOffline;
return countLabel(node.count, 'vehicle', dict);
}
function renderAMapMarkers() {
if (!map || !dashboard) return;
markerList.forEach(marker => marker.remove()); markerList = [];
infoWindowList.forEach(infoWindow => infoWindow.close()); infoWindowList = [];
const nodes = currentMode === 'vehicle' ? vehicleNodes() : stationNodes(true);
const dict = i18n[currentLang];
for (const node of nodes) {
const isVehicleNode = node.kind.startsWith('vehicle');
const isStationPoint = node.kind === 'station';
const markerContent = document.createElement('div');
markerContent.className = 'province-info-badge-wrap';
const label = markerLabel(node, dict);
const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : isStationPoint ? node.adminPath : `${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-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${(isVehicleNode || isStationPoint) && subline ? `<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,
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.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 === 'stationProvince' || node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
if (node.kind === 'stationCity') marker.on('click', () => map.setZoomAndCenter(11.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 = buildVehicleRegionNodes().nodes
.sort((a, b) => type === 'secondary' ? b.dist - a.dist : b.count - a.count)
.slice(0, 12)
.map(item => ({
name: item.name,
value: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
location: item.lnglat,
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14
}));
} else {
const { level, allNodes, visibleNodes } = stationViewportSummary();
const byHydrogen = type === 'secondary';
rows = [...visibleNodes]
.filter(node => !byHydrogen || node.hasHydrogen)
.sort((a, b) => byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
.map(node => ({
name: node.name,
value: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
location: node.lnglat,
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14
}));
updateStationViewportMeta(visibleNodes.length, allNodes.length);
if (!rows.length) {
const message = byHydrogen && visibleNodes.length ? i18n[currentLang].rankNoHydrogenData : i18n[currentLang].stationViewportEmpty;
box.innerHTML = `<div class="rank-empty rank-empty-viewport"><span class="rank-empty-icon">⌖</span><span>${escapeHTML(message)}</span></div>`;
}
}
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(item.zoom || 9, item.location);
box.appendChild(row);
});
}
function updateStationViewportMeta(visibleCount, totalCount) {
const meta = document.getElementById('rankViewportMeta');
if (!meta) return;
if (currentMode !== 'station') {
meta.hidden = true;
return;
}
const dict = i18n[currentLang];
meta.hidden = false;
meta.textContent = `${dict.viewportNow} ${formatNumber(visibleCount)} · ${dict.viewportAll} ${formatNumber(totalCount)}`;
}
function updateRankingControls() {
const dict = i18n[currentLang];
const stationLevel = stationHierarchyLevelForZoom();
const title = currentMode === 'vehicle'
? dict.panelRankVehicle
: stationLevel === 'province' ? dict.panelRankStation : stationLevel === 'city'
? (currentLang === 'zh' ? '加氢站城市 TOP 排名' : 'Top Cities by Stations')
: (currentLang === 'zh' ? '加氢站 TOP 排名' : 'Top H₂ Stations');
document.getElementById('panelRankTitle').textContent = title;
if (currentMode === 'vehicle') updateStationViewportMeta(0, 0);
document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations;
document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle' ? dict.rankByDist : dict.rankByHydrogen;
document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab')));
}
function switchRankTab(type) {
currentRankType = type;
updateRankingControls();
renderRankingList(type);
}
function switchMode(mode) {
currentMode = mode;
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
currentRankType = 'primary';
updateDashboardUI();
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
}
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];
});
const brandLogo = document.getElementById('brandLogo');
const brandLogoEnglish = document.getElementById('brandLogoEnglish');
if (brandLogo) {
const english = lang === 'en';
brandLogo.hidden = english;
brandLogoEnglish.hidden = !english;
brandLogo.alt = '羚牛氢能';
}
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[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);
}