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

1638 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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: '台', btnLocate: '定位', locating: '定位中', located: '已定位', locateFailed: '定位失败',
btnClearFilters: '清除', filterAllProvince: '全部省份', filterAllCity: '全部城市', filterAllDistrict: '全部区县',
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量', detailNavigate: '导航',
navigationLaunchingTitle: '正在打开高德地图', navigationLaunchingDescription: '正在准备导航路线,请稍候',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象',
suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息'
},
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', btnLocate: 'Locate', locating: 'Locating', located: 'Located', locateFailed: 'Location failed',
btnClearFilters: 'Clear', filterAllProvince: 'All provinces', filterAllCity: 'All cities', filterAllDistrict: 'All districts',
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen', detailNavigate: 'Navigate',
navigationLaunchingTitle: 'Opening AMap', navigationLaunchingDescription: 'Preparing your route. Just a moment.',
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity',
suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details'
}
};
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 = 'station';
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 };
let filterSyncVersion = 0;
let filterDebounceTimer = null;
let selectedEntity = null;
let userLocation = null;
let userLocationMarker = null;
let vehicleFilterGroups = { provinces: [], cities: [], districts: [] };
let vehicleExploreCatalog = { ready: false, loading: null, options: [], provinces: [], cities: [], districts: [] };
let exploreSuggestionItems = [];
let activeExploreSuggestionIndex = -1;
let detailExpanded = false;
let navigationLaunchTimer = null;
const filterState = { province: '', city: '', district: '', query: '' };
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);
});
document.addEventListener('pointerdown', event => {
if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions();
});
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;
void syncFilterControls(payload);
updateDashboardUI();
if (currentMode === 'vehicle') refreshVehicleRegionNodes(payload);
else refreshStationViewport();
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 normalizeSearch(value) {
return String(value || '').trim().toLocaleLowerCase();
}
function stationDistrictName(station) {
if (station?.district) return String(station.district).trim();
let address = String(station?.address || '').trim();
for (const prefix of [station?.province, station?.city]) {
const normalized = String(prefix || '').trim();
if (normalized) address = address.split(normalized).join('');
}
return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || '';
}
function vehicleMatchesQuery(vehicle, query = filterState.query) {
const needle = normalizeSearch(query);
if (!needle) return true;
return [vehicle?.plateNumber, vehicle?.vin].some(value => normalizeSearch(value).includes(needle));
}
function stationMatchesQuery(station, query = filterState.query) {
const needle = normalizeSearch(query);
if (!needle) return true;
return [station?.name, station?.shortName, station?.address].some(value => normalizeSearch(value).includes(needle));
}
function selectedVehicleFilterGroup() {
if (filterState.district) return vehicleFilterGroups.districts.find(group => group.adcode === filterState.district) || null;
if (filterState.city) return vehicleFilterGroups.cities.find(group => group.adcode === filterState.city) || null;
if (filterState.province) return vehicleFilterGroups.provinces.find(group => group.adcode === filterState.province) || null;
return null;
}
function hasPinnedVehicleDistrictFilter() {
return currentMode === 'vehicle' && Boolean(filterState.district) && !normalizeSearch(filterState.query);
}
function filteredVehicles(vehicles = dashboard?.vehicles || []) {
const group = selectedVehicleFilterGroup();
const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null;
return vehicles.filter(vehicle => (!allowedVins || allowedVins.has(vehicle.vin)) && vehicleMatchesQuery(vehicle));
}
function filteredStations(stations = dashboard?.stations || []) {
return stations.filter(station => {
if (filterState.province && station.province !== filterState.province) return false;
if (filterState.city && station.city !== filterState.city) return false;
if (filterState.district && stationDistrictName(station) !== filterState.district) return false;
return stationMatchesQuery(station);
});
}
function isFilterActive() {
return Boolean(filterState.province || filterState.city || filterState.district || normalizeSearch(filterState.query));
}
function uniqueSortedOptions(values) {
return [...new Set(values.filter(Boolean).map(value => String(value).trim()).filter(Boolean))]
.sort((left, right) => left.localeCompare(right, 'zh-CN'));
}
function filterGroupLabel(group, level) {
if (!group) return '';
return currentLang === 'en' ? administrativeEnglishName(group.rawName || group.nameZh, level) : group.nameZh;
}
function populateSelect(selectId, options, value, allLabel, disabled = false) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = '';
const all = document.createElement('option'); all.value = ''; all.textContent = allLabel; select.appendChild(all);
for (const option of options) {
const element = document.createElement('option');
element.value = option.value; element.textContent = option.label; select.appendChild(element);
}
select.value = options.some(option => option.value === value) ? value : '';
select.disabled = disabled;
}
function syncStationFilterControls() {
const dict = i18n[currentLang];
const stations = dashboard?.stations || [];
const provinces = uniqueSortedOptions(stations.map(station => station.province));
if (filterState.province && !provinces.includes(filterState.province)) {
filterState.province = ''; filterState.city = ''; filterState.district = '';
}
const provinceStations = filterState.province ? stations.filter(station => station.province === filterState.province) : [];
const cities = uniqueSortedOptions(provinceStations.map(station => station.city));
if (filterState.city && !cities.includes(filterState.city)) { filterState.city = ''; filterState.district = ''; }
const cityStations = filterState.city ? provinceStations.filter(station => station.city === filterState.city) : [];
const districts = uniqueSortedOptions(cityStations.map(stationDistrictName));
if (filterState.district && !districts.includes(filterState.district)) filterState.district = '';
populateSelect('provinceFilter', provinces.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'province') : compactProvinceName(value) })), filterState.province, dict.filterAllProvince);
populateSelect('cityFilter', cities.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'city') : compactRegionName(value) })), filterState.city, dict.filterAllCity, !filterState.province);
populateSelect('districtFilter', districts.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'district') : compactRegionName(value) })), filterState.district, dict.filterAllDistrict, !filterState.city);
}
async function buildVehicleFilterGroups(vehicles) {
const nationalNode = await areaNodeFor(100000);
const provinces = partitionVehiclesByArea(nationalNode, locatedVehicles(vehicles), 'province').groups;
let cities = [];
let districts = [];
const province = provinces.find(group => group.adcode === filterState.province);
if (province) cities = await expandRegionGroups([province], 'city');
const city = cities.find(group => group.adcode === filterState.city);
if (city) districts = await expandRegionGroups([city], 'district');
return { provinces, cities, districts };
}
function syncVehicleFilterControls() {
const dict = i18n[currentLang];
const levels = [
['provinceFilter', vehicleFilterGroups.provinces, filterState.province, dict.filterAllProvince, 'province', false],
['cityFilter', vehicleFilterGroups.cities, filterState.city, dict.filterAllCity, 'city', !filterState.province],
['districtFilter', vehicleFilterGroups.districts, filterState.district, dict.filterAllDistrict, 'district', !filterState.city]
];
for (const [id, groups, value, allLabel, level, disabled] of levels) {
const options = groups.map(group => ({ value: group.adcode, label: filterGroupLabel(group, level) }))
.sort((left, right) => left.label.localeCompare(right.label, currentLang === 'zh' ? 'zh-CN' : 'en-US'));
populateSelect(id, options, value, allLabel, disabled);
}
}
async function syncFilterControls(sourceDashboard = dashboard) {
if (!sourceDashboard) return;
const version = ++filterSyncVersion;
if (currentMode === 'station') {
syncStationFilterControls();
updateFilterToolbar();
return;
}
try {
const groups = await buildVehicleFilterGroups(sourceDashboard.vehicles || []);
if (version !== filterSyncVersion || sourceDashboard !== dashboard || currentMode !== 'vehicle') return;
vehicleFilterGroups = groups;
if (filterState.province && !groups.provinces.some(group => group.adcode === filterState.province)) {
filterState.province = ''; filterState.city = ''; filterState.district = '';
}
if (filterState.city && !groups.cities.some(group => group.adcode === filterState.city)) {
filterState.city = ''; filterState.district = '';
}
if (filterState.district && !groups.districts.some(group => group.adcode === filterState.district)) filterState.district = '';
syncVehicleFilterControls();
updateFilterToolbar();
} catch (error) {
console.warn('vehicle filter regions unavailable', error);
if (version === filterSyncVersion) updateFilterToolbar();
}
}
function pinyinSearchVariants(value) {
const text = String(value || '');
const converter = globalThis.pinyinPro?.pinyin;
if (typeof converter !== 'function' || !text) return [];
const spaced = String(converter(text, { toneType: 'none', separator: ' ' }) || '');
const compact = spaced.replace(/\s+/g, '').toLocaleLowerCase();
const initials = spaced.split(/\s+/).filter(Boolean).map(part => part[0]).join('').toLocaleLowerCase();
return [compact, initials].filter(Boolean);
}
function fuzzySearchScore(value, query) {
const needle = normalizeSearch(query);
if (!needle) return 4;
const normalized = normalizeSearch(value);
if (normalized === needle) return 0;
if (normalized.startsWith(needle)) return 1;
if (normalized.includes(needle)) return 2;
const variants = pinyinSearchVariants(value);
if (variants.some(item => item === needle || item.startsWith(needle))) return 3;
if (variants.some(item => item.includes(needle))) return 4;
return Number.POSITIVE_INFINITY;
}
function compactLocationPath(parts) {
return parts.filter(Boolean).join(' · ');
}
function stationLocationOptions() {
const unique = new Map();
const add = option => {
const key = [option.level, option.province, option.city, option.district].join('|');
if (!unique.has(key)) unique.set(key, option);
};
for (const station of dashboard?.stations || []) {
const province = station.province || '';
const city = station.city || '';
const district = stationDistrictName(station);
if (province) add({ type: 'location', level: 'province', province, city: '', district: '', label: currentLang === 'en' ? administrativeEnglishName(province, 'province') : compactProvinceName(province), path: compactLocationPath([stationAdministrativePath({ province })]) });
if (province && city) add({ type: 'location', level: 'city', province, city, district: '', label: currentLang === 'en' ? administrativeEnglishName(city, 'city') : compactRegionName(city), path: compactLocationPath([stationAdministrativePath({ province, city })]) });
if (province && city && district) add({ type: 'location', level: 'district', province, city, district, label: currentLang === 'en' ? administrativeEnglishName(district, 'district') : compactRegionName(district), path: compactLocationPath([stationAdministrativePath({ province, city }), currentLang === 'en' ? administrativeEnglishName(district, 'district') : district]) });
}
return [...unique.values()];
}
function groupLocationName(group, level) {
return currentLang === 'en' ? administrativeEnglishName(group.rawName || group.nameZh, level) : group.nameZh;
}
function uniqueGroups(groups) {
const values = new Map();
for (const group of groups) if (!values.has(group.adcode)) values.set(group.adcode, group);
return [...values.values()];
}
async function ensureVehicleExploreCatalog() {
if (vehicleExploreCatalog.ready) return vehicleExploreCatalog;
if (vehicleExploreCatalog.loading) return vehicleExploreCatalog.loading;
vehicleExploreCatalog.loading = (async () => {
const vehicles = dashboard?.vehicles || [];
const nationalNode = await areaNodeFor(100000);
const provinces = partitionVehiclesByArea(nationalNode, locatedVehicles(vehicles), 'province').groups;
const cities = uniqueGroups((await Promise.all(provinces.map(async province => {
const children = await expandRegionGroups([province], 'city');
return children.map(city => ({ ...city, parentAdcode: province.adcode, province }));
}))).flat());
const districts = uniqueGroups((await Promise.all(cities.map(async city => {
const children = await expandRegionGroups([city], 'district');
return children.map(district => ({ ...district, parentAdcode: city.adcode, city, province: city.province }));
}))).flat());
const provinceByCode = new Map(provinces.map(group => [group.adcode, group]));
const cityByCode = new Map(cities.map(group => [group.adcode, group]));
const options = [];
for (const province of provinces) {
const label = groupLocationName(province, 'province');
options.push({ type: 'location', level: 'province', label, path: label, provinceGroup: province });
}
for (const city of cities) {
const province = provinceByCode.get(city.parentAdcode) || city.province;
const label = groupLocationName(city, 'city');
options.push({ type: 'location', level: 'city', label, path: compactLocationPath([province && groupLocationName(province, 'province'), label]), provinceGroup: province, cityGroup: city });
}
for (const district of districts) {
const city = cityByCode.get(district.parentAdcode) || district.city;
const province = city?.province || district.province || provinceByCode.get(city?.parentAdcode);
const label = groupLocationName(district, 'district');
options.push({ type: 'location', level: 'district', label, path: compactLocationPath([province && groupLocationName(province, 'province'), city && groupLocationName(city, 'city'), label]), provinceGroup: province, cityGroup: city, districtGroup: district });
}
vehicleExploreCatalog = { ready: true, loading: null, options, provinces, cities, districts };
return vehicleExploreCatalog;
})().catch(error => {
vehicleExploreCatalog.loading = null;
console.warn('vehicle location search unavailable', error);
return vehicleExploreCatalog;
});
return vehicleExploreCatalog.loading;
}
function currentLocationOptions() {
if (currentMode === 'station') return stationLocationOptions();
if (vehicleExploreCatalog.ready) return vehicleExploreCatalog.options;
return vehicleFilterGroups.provinces.map(group => {
const label = groupLocationName(group, 'province');
return { type: 'location', level: 'province', label, path: label, provinceGroup: group };
});
}
function locationSuggestionItems(query) {
const needle = normalizeSearch(query);
return currentLocationOptions()
.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) }))
.filter(option => Number.isFinite(option.score))
.sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN'))
.slice(0, needle ? 8 : 6);
}
function entitySuggestionItems(query) {
const needle = normalizeSearch(query);
if (!needle) return [];
const entities = currentMode === 'vehicle' ? filteredVehicles() : filteredStations();
return entities.slice(0, 5).map(entity => ({ type: 'entity', mode: currentMode, entity }));
}
function renderExploreSuggestions() {
const box = document.getElementById('exploreSuggestions');
if (!box || box.hidden) return;
const dict = i18n[currentLang];
const query = filterState.query;
const locations = locationSuggestionItems(query);
const entities = entitySuggestionItems(query);
exploreSuggestionItems = [...locations, ...entities];
activeExploreSuggestionIndex = Math.min(activeExploreSuggestionIndex, exploreSuggestionItems.length - 1);
if (!exploreSuggestionItems.length) {
box.innerHTML = `<span class="suggestion-empty">${escapeHTML(dict.searchEmpty)}</span>`;
return;
}
let html = '';
if (locations.length) {
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchLocations)}</span>`;
html += locations.map((item, index) => `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol is-location">⌖</span><span class="suggestion-copy"><strong>${escapeHTML(item.label)}</strong><small>${escapeHTML(item.path)}</small></span><span class="suggestion-kind">${escapeHTML(dict.suggestionLocation)}</span></button>`).join('');
}
if (entities.length) {
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchEntities)}</span>`;
html += entities.map((item, entityIndex) => {
const index = locations.length + entityIndex;
const isStation = item.mode === 'station';
const name = isStation ? item.entity.name || item.entity.shortName : item.entity.plateNumber || item.entity.vin;
const subline = isStation ? stationAdministrativePath(item.entity) : item.entity.vin;
return `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol">${isStation ? 'H₂' : '车'}</span><span class="suggestion-copy"><strong>${escapeHTML(name)}</strong><small>${escapeHTML(subline || '')}</small></span><span class="suggestion-kind">${escapeHTML(isStation ? dict.suggestionStation : dict.suggestionVehicle)}</span></button>`;
}).join('');
}
box.innerHTML = html;
}
function openExploreSuggestions() {
const box = document.getElementById('exploreSuggestions');
if (!box) return;
box.hidden = false;
if (currentMode === 'vehicle' && !vehicleExploreCatalog.ready) {
void ensureVehicleExploreCatalog().then(() => renderExploreSuggestions());
}
renderExploreSuggestions();
}
function closeExploreSuggestions() {
const box = document.getElementById('exploreSuggestions');
if (box) box.hidden = true;
activeExploreSuggestionIndex = -1;
}
async function selectExploreLocation(option) {
filterState.query = '';
if (currentMode === 'station') {
filterState.province = option.province || '';
filterState.city = option.city || '';
filterState.district = option.district || '';
} else {
vehicleFilterGroups = {
provinces: vehicleExploreCatalog.provinces.length ? vehicleExploreCatalog.provinces : vehicleFilterGroups.provinces,
cities: vehicleExploreCatalog.cities.length ? vehicleExploreCatalog.cities : vehicleFilterGroups.cities,
districts: vehicleExploreCatalog.districts.length ? vehicleExploreCatalog.districts : vehicleFilterGroups.districts
};
filterState.province = option.provinceGroup?.adcode || '';
filterState.city = option.cityGroup?.adcode || '';
filterState.district = option.districtGroup?.adcode || '';
}
closeExploreSuggestions();
updateFilterToolbar();
focusCurrentRegion(option.level);
applyActiveFilters();
void syncFilterControls(dashboard);
}
function selectExploreEntity(item) {
const entity = item.entity;
if (!entity) return;
closeExploreSuggestions();
const coordinates = item.mode === 'vehicle'
? entity.locationAvailable ? wgs84ToGcj02(Number(entity.longitude), Number(entity.latitude)) : null
: [Number(entity.longitude), Number(entity.latitude)];
if (coordinates?.every(Number.isFinite)) map?.setZoomAndCenter(14, coordinates);
showEntityDetails(item.mode, entity);
}
function selectExploreSuggestion(index) {
const item = exploreSuggestionItems[index];
if (!item) return;
if (item.type === 'location') void selectExploreLocation(item);
else selectExploreEntity(item);
}
function handleExploreSearchKeydown(event) {
if (event.key === 'Escape') { closeExploreSuggestions(); event.currentTarget.blur(); return; }
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return;
const box = document.getElementById('exploreSuggestions');
if (box?.hidden) openExploreSuggestions();
if (event.key === 'Enter' && activeExploreSuggestionIndex >= 0) {
event.preventDefault(); selectExploreSuggestion(activeExploreSuggestionIndex); return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
activeExploreSuggestionIndex = (activeExploreSuggestionIndex + direction + exploreSuggestionItems.length) % Math.max(exploreSuggestionItems.length, 1);
renderExploreSuggestions();
}
}
function selectedLocationLabel(level) {
if (currentMode === 'station') {
const raw = filterState[level];
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
}
const group = level === 'province' ? vehicleFilterGroups.provinces.find(item => item.adcode === filterState.province)
: level === 'city' ? vehicleFilterGroups.cities.find(item => item.adcode === filterState.city)
: vehicleFilterGroups.districts.find(item => item.adcode === filterState.district);
return group ? groupLocationName(group, level) : '';
}
function renderFilterChips() {
const rail = document.getElementById('filterChipRail');
if (!rail) return;
const levels = ['province', 'city', 'district'].filter(level => filterState[level]);
rail.hidden = !levels.length;
rail.innerHTML = levels.map(level => `<span class="filter-chip">${escapeHTML(selectedLocationLabel(level))}<button type="button" onclick="clearLocationChip('${level}')" aria-label="清除${escapeHTML(selectedLocationLabel(level))}">×</button></span>`).join('');
}
function clearLocationChip(level) {
filterState[level] = '';
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
if (level === 'city') filterState.district = '';
void syncFilterControls(dashboard);
applyActiveFilters();
}
function updateFilterToolbar() {
const dict = i18n[currentLang];
const search = document.getElementById('entitySearch');
const searchClear = document.getElementById('searchClearBtn');
const meta = document.getElementById('filterResultMeta');
if (search) {
search.placeholder = dict.searchUniversal;
search.setAttribute('aria-label', dict.searchUniversal);
if (search.value !== filterState.query) search.value = filterState.query;
}
if (searchClear) searchClear.hidden = !normalizeSearch(filterState.query);
const all = currentMode === 'vehicle' ? dashboard?.vehicles?.length || 0 : dashboard?.stations?.length || 0;
const filtered = currentMode === 'vehicle' ? filteredVehicles().length : filteredStations().length;
if (meta) meta.textContent = isFilterActive() ? `${dict.filterResult} ${formatNumber(filtered)} / ${formatNumber(all)}` : `${dict.viewportAll} ${formatNumber(all)}`;
renderFilterChips();
renderExploreSuggestions();
}
function focusCoordinates(items, zoom) {
const located = items.filter(item => Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude)));
if (!map || !located.length) return;
const center = located.reduce((sum, item) => [sum[0] + Number(item.longitude), sum[1] + Number(item.latitude)], [0, 0]);
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
}
function focusVehicleSet(vehicles) {
const points = locatedVehicles(vehicles).map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
if (!map || !points.length) return;
if (points.length === 1) {
map.setZoomAndCenter(14, points[0]);
return;
}
const longitudes = points.map(point => point[0]);
const latitudes = points.map(point => point[1]);
const minLng = Math.min(...longitudes); const maxLng = Math.max(...longitudes);
const minLat = Math.min(...latitudes); const maxLat = Math.max(...latitudes);
const center = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
const latitudeScale = Math.cos(center[1] * Math.PI / 180);
const spanMeters = Math.max((maxLat - minLat) * 111320, (maxLng - minLng) * 111320 * latitudeScale, 400);
const size = map.getSize?.();
const width = Number(size?.getWidth?.()) || 900;
const height = Number(size?.getHeight?.()) || 560;
// Reserve room for the toolbar, detail card and map controls on every viewport.
const usablePixels = Math.max(180, Math.min(width * 0.72, height * 0.68));
const metersPerPixel = spanMeters / usablePixels;
const zoom = Math.max(7, Math.min(14, Math.log2((156543.03392 * latitudeScale) / metersPerPixel)));
map.setZoomAndCenter(zoom, center);
}
function focusCurrentRegion(level) {
if (!map) return;
if (currentMode === 'vehicle') {
const group = selectedVehicleFilterGroup();
if (level === 'district' && hasPinnedVehicleDistrictFilter() && locatedVehicles(filteredVehicles()).length) {
// District borders can be elongated. Fit the actual filtered fleet rather
// than centering on the administrative centroid, which can hide a valid car.
focusVehicleSet(filteredVehicles());
return;
}
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
return;
}
const stations = filteredStations();
if (stations.length) focusCoordinates(stations, level === 'province' ? 7.4 : level === 'city' ? 10.5 : 13);
}
function focusUniqueQueryResult() {
if (!normalizeSearch(filterState.query)) return;
const matches = currentMode === 'vehicle' ? filteredVehicles() : filteredStations();
if (matches.length === 1) {
const item = matches[0];
if (Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude))) {
const coordinates = currentMode === 'vehicle'
? wgs84ToGcj02(Number(item.longitude), Number(item.latitude))
: [Number(item.longitude), Number(item.latitude)];
map?.setZoomAndCenter(14, coordinates);
}
showEntityDetails(currentMode, item);
}
}
function applyActiveFilters({ focusQuery = false } = {}) {
closeEntityDetails();
updateFilterToolbar();
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
if (focusQuery) window.setTimeout(focusUniqueQueryResult, 0);
}
function handleExploreInput(value) {
filterState.query = value;
openExploreSuggestions();
updateFilterToolbar();
window.clearTimeout(filterDebounceTimer);
filterDebounceTimer = window.setTimeout(() => applyActiveFilters({ focusQuery: true }), 180);
}
function handleNameFilterInput(value) { handleExploreInput(value); }
function clearNameFilter() {
filterState.query = '';
closeExploreSuggestions();
applyActiveFilters();
}
async function handleRegionFilterChange(level, value) {
filterState[level] = value;
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
if (level === 'city') filterState.district = '';
await syncFilterControls(dashboard);
focusCurrentRegion(level);
applyActiveFilters();
}
function clearAllFilters() {
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
closeExploreSuggestions();
closeEntityDetails();
resetMapView();
void syncFilterControls(dashboard);
applyActiveFilters();
}
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(); renderUserLocationMarker(); });
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
? `—${activity.unit ? ` <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();
updateFilterToolbar();
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} 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 (hasPinnedVehicleDistrictFilter()) return 'vehicle';
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 = filteredVehicles()) {
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 = filteredVehicles()) {
const bounds = currentMapBounds();
return locatedVehicles(vehicles).map(vehicle => {
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
return { vehicle, lnglat };
}).filter(({ lnglat }) => hasPinnedVehicleDistrictFilter() || !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 = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
const version = ++regionAggregationVersion;
const vehicles = filteredVehicles(sourceDashboard.vehicles || []);
const unassigned = vehicles.length - locatedVehicles(vehicles).length;
if (level === 'vehicle') {
vehicleRegionSummary = { level, nodes: vehiclePointNodes(vehicles), unassigned, loading: false };
updateDashboardUI();
return;
}
vehicleRegionSummary = { level, nodes: [], unassigned, loading: true };
updateDashboardUI();
try {
const result = await aggregateVehicleHierarchy(level, vehicles);
const currentLevel = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== currentLevel) 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 (normalizeSearch(filterState.query)) return 'station';
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 = filteredStations(), level = stationHierarchyLevelForZoom()) {
if (level === 'station') {
return stations.map(station => ({
id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude],
station,
count: 1, online: station.cooperative ? 1 : 0,
adminPath: [stationAdministrativePath(station), stationDistrictName(station)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · '),
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 entityKey(mode, entity) {
return mode === 'vehicle' ? `vehicle:${entity?.vin || ''}` : `station:${entity?.id || ''}`;
}
function nodeEntity(node) {
if (node?.kind === 'vehiclePoint') return { mode: 'vehicle', entity: node.vehicle || node.vehicles?.[0] };
if (node?.kind === 'station') return { mode: 'station', entity: node.station };
return null;
}
function isNodeSelected(node) {
const target = nodeEntity(node);
return Boolean(target?.entity && selectedEntity?.key === entityKey(target.mode, target.entity));
}
function detailValue(value, suffix = '') {
if (value == null || value === '') return i18n[currentLang].valueUnavailable;
return `${value}${suffix}`;
}
function detailField(label, value, wide = false, extra = false) {
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
}
function vehicleStatusLabel(vehicle) {
const status = vehicleActiveToday(vehicle)
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
: i18n[currentLang].statusOffline;
return status;
}
function navigationCoordinates(mode, entity) {
const longitude = Number(entity?.longitude);
const latitude = Number(entity?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
// Vehicle telemetry is WGS-84 and is converted before rendering on AMap.
// Station assets already use AMap (GCJ-02) coordinates.
return mode === 'vehicle' ? wgs84ToGcj02(longitude, latitude) : [longitude, latitude];
}
function navigationUrlForEntity(mode, entity) {
const coordinates = navigationCoordinates(mode, entity);
if (!coordinates?.every(Number.isFinite)) return null;
const name = mode === 'station'
? entity?.name || entity?.shortName || i18n[currentLang].detailStation
: entity?.plateNumber || entity?.vin || i18n[currentLang].detailVehicle;
const [longitude, latitude] = coordinates.map(value => Number(value).toFixed(6));
return `https://uri.amap.com/navigation?to=${longitude},${latitude},${encodeURIComponent(name)}&mode=car&coordinate=gaode&callnative=1`;
}
function updateDetailNavigation(mode, entity) {
const button = document.getElementById('detailNavigateBtn');
if (!button) return;
const url = navigationUrlForEntity(mode, entity);
const label = i18n[currentLang].detailNavigate;
button.hidden = !url;
button.disabled = !url;
button.dataset.navigationUrl = url || '';
button.setAttribute('aria-label', label);
button.title = label;
}
function resetNavigationLaunch() {
const overlay = document.getElementById('navigationLaunchOverlay');
const button = document.getElementById('detailNavigateBtn');
overlay?.classList.remove('is-visible');
window.setTimeout(() => { if (overlay) overlay.hidden = true; }, 180);
button?.classList.remove('is-launching');
if (button && button.dataset.navigationUrl) button.disabled = false;
}
function navigateToSelectedEntity() {
const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity);
if (!url) return;
const overlay = document.getElementById('navigationLaunchOverlay');
const button = document.getElementById('detailNavigateBtn');
if (button?.classList.contains('is-launching')) return;
if (navigationLaunchTimer) window.clearTimeout(navigationLaunchTimer);
if (overlay) {
overlay.hidden = false;
window.requestAnimationFrame(() => overlay.classList.add('is-visible'));
}
button?.classList.add('is-launching');
if (button) button.disabled = true;
// Paint the acknowledgement before Safari hands control to the AMap URL scheme.
navigationLaunchTimer = window.setTimeout(() => window.location.assign(url), 140);
window.setTimeout(resetNavigationLaunch, 4000);
}
function showEntityDetails(mode, entity) {
if (!entity) return;
const dict = i18n[currentLang];
selectedEntity = { mode, entity, key: entityKey(mode, entity) };
const card = document.getElementById('mapDetailCard');
const type = document.getElementById('detailTypePill');
const accent = document.getElementById('detailCardAccent');
const title = document.getElementById('detailTitle');
const subtitle = document.getElementById('detailSubtitle');
const grid = document.getElementById('detailGrid');
const expand = document.getElementById('detailExpandBtn');
if (!card || !type || !accent || !title || !subtitle || !grid || !expand) return;
detailExpanded = false;
card.classList.remove('is-expanded');
const isStation = mode === 'station';
type.textContent = isStation ? dict.detailStation : dict.detailVehicle;
type.classList.toggle('is-station', isStation);
accent.classList.toggle('is-station', isStation);
if (isStation) {
title.textContent = entity.name || entity.shortName || dict.detailStation;
subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity);
const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · ');
const publicFields = [
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
detailField(dict.detailAdmin, detailValue(admin)),
detailField(dict.detailAddress, detailValue(entity.address), true),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
];
const hydrogenFields = [
detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`),
detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true),
];
grid.innerHTML = [...publicFields, ...hydrogenFields].join('');
} else {
title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle;
subtitle.textContent = entity.vin || '';
const hasLocation = entity.locationAvailable && Number.isFinite(Number(entity.longitude)) && Number.isFinite(Number(entity.latitude));
grid.innerHTML = [
detailField(dict.detailPlate, detailValue(entity.plateNumber), false, true),
detailField(dict.detailVin, detailValue(entity.vin), false, true),
detailField(dict.detailStatus, vehicleStatusLabel(entity)),
detailField(dict.detailActive, vehicleActiveToday(entity) ? dict.valueYes : dict.valueNo),
detailField(dict.detailSpeed, entity.speedKmh == null ? dict.valueUnavailable : `${formatNumber(entity.speedKmh, 1)} km/h`),
detailField(dict.detailDailyMileage, `${formatNumber(entity.dailyMileageKm, 1)} km`),
detailField(dict.detailTotalMileage, entity.totalMileageKm == null ? dict.valueUnavailable : `${formatNumber(entity.totalMileageKm, 1)} km`, false, true),
detailField(dict.detailLocation, hasLocation ? dict.valueYes : dict.valueUnavailable, false, true),
detailField(dict.detailCoordinate, hasLocation ? `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}` : dict.valueUnavailable, true, true)
].join('');
}
expand.hidden = false;
expand.textContent = dict.detailMore;
updateDetailNavigation(mode, entity);
card.hidden = false;
renderAMapMarkers();
renderRankingList(currentRankType);
}
function toggleEntityDetails() {
const card = document.getElementById('mapDetailCard');
const expand = document.getElementById('detailExpandBtn');
if (!card || !expand || !selectedEntity) return;
detailExpanded = !detailExpanded;
card.classList.toggle('is-expanded', detailExpanded);
expand.textContent = detailExpanded ? i18n[currentLang].detailLess : i18n[currentLang].detailMore;
}
function showNodeDetails(node) {
const target = nodeEntity(node);
if (target?.entity) showEntityDetails(target.mode, target.entity);
}
function closeEntityDetails() {
selectedEntity = null;
detailExpanded = false;
const card = document.getElementById('mapDetailCard');
if (card) card.hidden = true;
if (dashboard) {
renderAMapMarkers();
renderRankingList(currentRankType);
}
}
function distanceKm(origin, target) {
if (!origin || !target) return Number.POSITIVE_INFINITY;
const toRadians = degrees => degrees * Math.PI / 180;
const deltaLat = toRadians(Number(target[1]) - Number(origin[1]));
const deltaLng = toRadians(Number(target[0]) - Number(origin[0]));
const lat1 = toRadians(Number(origin[1]));
const lat2 = toRadians(Number(target[1]));
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
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'}` : ''}${isNodeSelected(node) ? ' is-selected' : ''}"><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));
if (node.kind === 'vehiclePoint' || node.kind === 'station') marker.on('click', () => showNodeDetails(node));
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') {
if (normalizeSearch(filterState.query)) {
rows = filteredVehicles()
.sort((left, right) => Number(vehicleActiveToday(right)) - Number(vehicleActiveToday(left)) || String(left.plateNumber || left.vin).localeCompare(String(right.plateNumber || right.vin), 'zh-CN'))
.map(vehicle => {
const hasLocation = vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude));
const location = hasLocation ? wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)) : null;
return {
name: vehicle.plateNumber || vehicle.vin,
value: userLocation && location ? `${formatNumber(distanceKm(userLocation, location), 1)} km` : vehicleStatusLabel(vehicle),
location, zoom: 14, entity: vehicle, mode: 'vehicle'
};
});
} else {
rows = buildVehicleRegionNodes().nodes
.sort((a, b) => userLocation && a.kind === 'vehiclePoint'
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
: type === 'secondary' ? b.dist - a.dist : b.count - a.count)
.slice(0, 100)
.map(item => ({
name: item.name,
value: userLocation && item.kind === 'vehiclePoint' ? `${formatNumber(distanceKm(userLocation, item.lnglat), 1)} km`
: 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,
node: item, entity: item.kind === 'vehiclePoint' ? item.vehicle || item.vehicles?.[0] : null, mode: 'vehicle'
}));
}
} else {
const { level, allNodes, visibleNodes } = stationViewportSummary();
const byHydrogen = type === 'secondary';
rows = [...visibleNodes]
.filter(node => !byHydrogen || node.hasHydrogen)
.sort((a, b) => userLocation && level === 'station'
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
: byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
.map(node => ({
name: node.name,
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
location: node.lnglat,
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
}));
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${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
row.onclick = () => {
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
if (item.entity) showEntityDetails(item.mode, item.entity);
};
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 = userLocation && ((currentMode === 'vehicle' && vehicleRegionSummary.level === 'vehicle') || (currentMode === 'station' && stationLevel === 'station'))
? (currentMode === 'vehicle' ? dict.nearbyVehicles : dict.nearbyStations)
: 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;
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
closeExploreSuggestions();
selectedEntity = null;
const detailCard = document.getElementById('mapDetailCard');
if (detailCard) detailCard.hidden = true;
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
currentRankType = 'primary';
void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle');
updateDashboardUI();
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
}
function setLanguage(lang) {
currentLang = lang;
vehicleExploreCatalog = { ready: false, loading: null, options: [], provinces: [], cities: [], districts: [] };
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];
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder'); if (dict[key]) element.placeholder = 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 = '羚牛氢能';
}
void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle');
updateDashboardUI();
if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity);
}
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() { clearAllFilters(); }
function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } }
function locationCoordinates(position) {
const longitude = Number(position?.coords?.longitude);
const latitude = Number(position?.coords?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
return wgs84ToGcj02(longitude, latitude);
}
function updateLocateButton(state) {
const button = document.getElementById('locateDeviceBtn');
const label = document.getElementById('locateDeviceLabel');
if (!button || !label) return;
const dict = i18n[currentLang];
button.classList.toggle('is-loading', state === 'loading');
button.classList.toggle('is-active', state === 'active');
button.classList.toggle('is-error', state === 'error');
button.disabled = state === 'loading';
label.textContent = state === 'loading' ? dict.locating : state === 'active' ? dict.located : state === 'error' ? dict.locateFailed : dict.btnLocate;
}
function renderUserLocationMarker() {
if (!map || !userLocation || typeof AMap === 'undefined') return;
if (userLocationMarker) userLocationMarker.remove();
const content = document.createElement('div'); content.className = 'user-location-marker';
userLocationMarker = new AMap.Marker({
position: userLocation,
content,
offset: new AMap.Pixel(-9, -9),
title: currentLang === 'zh' ? '我的位置' : 'My location',
zIndex: 300
});
userLocationMarker.setMap(map);
}
function handleLocationSuccess(position) {
const coordinates = locationCoordinates(position);
if (!coordinates) { handleLocationError(); return; }
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
selectedEntity = null;
const detailCard = document.getElementById('mapDetailCard');
if (detailCard) detailCard.hidden = true;
void syncFilterControls(dashboard);
const feedback = document.getElementById('locationFeedback');
if (feedback) feedback.hidden = true;
userLocation = coordinates;
updateLocateButton('active');
renderUserLocationMarker();
map?.setZoomAndCenter(13, coordinates);
const hint = document.getElementById('selectedRegionHint');
if (hint) hint.textContent = currentLang === 'zh' ? '视角: 我的位置附近' : 'View: Near my location';
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
}
function handleLocationError(error) {
const dict = i18n[currentLang];
const feedback = document.getElementById('locationFeedback');
if (feedback) {
feedback.textContent = error?.code === 1 ? dict.locatePermissionHint : error?.code === 3 ? dict.locateTimeoutHint : dict.locateUnavailableHint;
feedback.hidden = false;
window.setTimeout(() => { feedback.hidden = true; }, 5200);
}
updateLocateButton('error');
window.setTimeout(() => updateLocateButton(userLocation ? 'active' : 'idle'), 2400);
}
function locateDevice() {
if (!navigator.geolocation) { handleLocationError(); return; }
updateLocateButton('loading');
navigator.geolocation.getCurrentPosition(handleLocationSuccess, handleLocationError, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 30000
});
}
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);
}