/** 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 = `${escapeHTML(dict.searchEmpty)}`; return; } let html = ''; if (locations.length) { html += `${escapeHTML(dict.searchLocations)}`; html += locations.map((item, index) => ``).join(''); } if (entities.length) { html += `${escapeHTML(dict.searchEntities)}`; 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 ``; }).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 => `${escapeHTML(selectedLocationLabel(level))}`).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)} ${unit}`; document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} ${countUnit(active, currentMode, dict)} (${activeRate}%)`; 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 ? ` ${activity.unit}` : ''}` : `${formatNumber(activity.value, 1)} ${activity.unit}`; 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])} ${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}`; 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 `