/** 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: '导航', valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试', locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试', searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象', suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息', publicStationTitle: '加氢站网络', publicDataStatus: '公共站点目录 · 位置、地址与合作状态', publicDataSource: '公共站点网络', authEntry: '运营授权', authGranted: '已授权', authTitle: '进入运营数据', authDescription: '车辆全量信息与加氢量仅向获得授权的用户展示。', authCodeLabel: '访问码', authCodePlaceholder: '输入访问码', authSubmit: '验证并进入', authCancel: '暂不授权', authRequired: '运营数据需要授权访问', authExpiry: '本次授权有效期为 30 分钟,到期后将自动恢复为公开站点视图。', authDenied: '访问码无效,请重试', authUnavailable: '授权服务暂不可用,请稍后重试', authHydrogenRequired: '授权后查看加氢量', publicHydrogenHidden: '运营数据受保护', authLogout: '退出授权' }, 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', 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', publicStationTitle: 'H₂ Station Network', publicDataStatus: 'Public station directory · location, address and partner status', publicDataSource: 'Public station network', authEntry: 'Operations access', authGranted: 'Access granted', authTitle: 'Enter operations data', authDescription: 'Vehicle information and hydrogen volumes require authorized access.', authCodeLabel: 'Access code', authCodePlaceholder: 'Enter access code', authSubmit: 'Verify and continue', authCancel: 'Not now', authRequired: 'Operations data requires authorized access', authExpiry: 'This authorization lasts 30 minutes. When it expires, the public station view is restored.', authDenied: 'The access code is invalid. Try again.', authUnavailable: 'Authorization is temporarily unavailable. Try again later.', authHydrogenRequired: 'Authorize to view hydrogen volume', publicHydrogenHidden: 'Operations data protected', authLogout: 'Sign out' } }; 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; const filterState = { province: '', city: '', district: '', query: '' }; let accessState = { authorized: false, principal: null, pendingMode: null }; const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 }; const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']); document.addEventListener('DOMContentLoaded', () => { initClock(); initAMapInstance(); initializeDataAccess(); }); document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions(); }); async function initializeDataAccess() { await loadSession(); await loadPublicStations(); if (accessState.authorized) loadOperationalDashboard(); refreshTimer = window.setInterval(() => { if (accessState.authorized) loadOperationalDashboard(); else loadPublicStations(); }, 15000); } async function loadSession() { try { const response = await fetch('/api/session', { headers: { Accept: 'application/json' } }); const payload = await response.json(); accessState.authorized = Boolean(payload.authorized); accessState.principal = payload.principal || null; } catch (error) { accessState.authorized = false; accessState.principal = null; } updateAccessUI(); } async function loadPublicStations() { setDataState('loading'); try { const response = await fetch('/api/public/stations', { headers: { Accept: 'application/json' } }); const payload = await response.json(); if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`); if (!accessState.authorized || currentMode === 'station') dashboard = payload; void syncFilterControls(payload); updateDashboardUI(); if (currentMode === 'station') refreshStationViewport(); setDataState('ready'); } catch (error) { console.error('public station refresh failed', error); setDataState('error'); } } async function loadOperationalDashboard() { setDataState('loading'); try { const response = await fetch('/api/dashboard', { headers: { Accept: 'application/json' } }); const payload = await response.json(); if (response.status === 401) { accessState.authorized = false; accessState.principal = null; updateAccessUI(); if (currentMode === 'vehicle') switchMode('station'); await loadPublicStations(); return; } 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('operational 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 operationalAccessGranted() { return Boolean(accessState.authorized); } function updateAccessUI() { const dict = i18n[currentLang]; const trigger = document.getElementById('accessTrigger'); if (trigger) { trigger.classList.toggle('is-authorized', operationalAccessGranted()); trigger.textContent = operationalAccessGranted() ? dict.authGranted : dict.authEntry; trigger.setAttribute('aria-label', operationalAccessGranted() ? dict.authLogout : dict.authEntry); trigger.title = operationalAccessGranted() ? dict.authLogout : dict.authEntry; } document.getElementById('btnModeVehicle')?.classList.toggle('is-locked', !operationalAccessGranted()); } function openAccessDialog(targetMode = 'vehicle', message = '') { accessState.pendingMode = targetMode; const dict = i18n[currentLang]; const dialog = document.getElementById('accessDialog'); const hint = document.getElementById('accessDialogHint'); const input = document.getElementById('accessCodeInput'); if (hint) hint.textContent = message || dict.authDescription; if (input) input.value = ''; if (dialog?.showModal) dialog.showModal(); else dialog?.setAttribute('open', ''); window.setTimeout(() => input?.focus(), 0); } function closeAccessDialog() { const dialog = document.getElementById('accessDialog'); if (dialog?.close) dialog.close(); else dialog?.removeAttribute('open'); const error = document.getElementById('accessDialogError'); if (error) { error.hidden = true; error.textContent = ''; } } async function authorizeOperationsAccess() { const dict = i18n[currentLang]; const input = document.getElementById('accessCodeInput'); const submit = document.getElementById('accessSubmitBtn'); const error = document.getElementById('accessDialogError'); const accessCode = input?.value?.trim(); if (!accessCode) { if (error) { error.hidden = false; error.textContent = dict.authDenied; } input?.focus(); return; } if (submit) submit.disabled = true; if (error) error.hidden = true; try { const response = await fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ accessCode }), }); const payload = await response.json(); if (!response.ok || !payload.authorized) { if (error) { error.hidden = false; error.textContent = response.status === 503 ? dict.authUnavailable : dict.authDenied; } return; } accessState.authorized = true; accessState.principal = payload.principal || null; updateAccessUI(); closeAccessDialog(); await loadOperationalDashboard(); const targetMode = accessState.pendingMode || 'vehicle'; accessState.pendingMode = null; if (targetMode === 'vehicle') switchMode('vehicle', { skipAuthCheck: true }); else updateDashboardUI(); } catch (requestError) { if (error) { error.hidden = false; error.textContent = dict.authUnavailable; } } finally { if (submit) submit.disabled = false; } } async function toggleOperationsAccess() { if (!operationalAccessGranted()) { openAccessDialog('vehicle'); return; } try { await fetch('/api/session', { method: 'DELETE', headers: { Accept: 'application/json' } }); } catch (error) { /* local state still resets safely */ } accessState.authorized = false; accessState.principal = null; accessState.pendingMode = null; updateAccessUI(); if (currentMode === 'vehicle') switchMode('station', { skipAuthCheck: true }); await loadPublicStations(); } 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' } : operationalAccessGranted() ? { label: dict.kpiMonthlyHydrogen, value: summary.monthlyHydrogenKg, unit: 'kg' } : { label: dict.publicHydrogenHidden, value: null, unit: '' }; 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); const accessHint = operationalAccessGranted() ? '' : lang === 'zh' ? ' · 站点目录公开展示' : ' · public station directory'; return lang === 'zh' ? `开放平台已同步 · ${summary.totalStations}座加氢站 · ${cooperative}座合作站点 · ${external}座外部站点${accessHint} · ${asOf}` : `Open platform synced · ${summary.totalStations} H₂ stations · ${cooperative} partner stations · ${external} external stations${accessHint} · ${asOf}`; } const regionText = regionSummary.loading ? regionLoadingText(regionSummary.level, lang) : regionSummaryText(regionSummary, lang); return lang === 'zh' ? `开放平台已同步 · ${summary.totalVehicles}辆授权车辆 · ${regionText} · ${asOf}` : `Open platform synced · ${summary.totalVehicles} authorized vehicles · ${regionText} · ${asOf}`; } function modeKpiLabels(dict = i18n[currentLang], mode = currentMode) { return mode === 'vehicle' ? { total: dict.kpiTotalFleet, active: dict.kpiOnlineFleet } : { total: dict.kpiTotalStation, active: dict.kpiCooperativeStation }; } function updateStatusPanel() { const dict = i18n[currentLang]; const summary = dashboard.summary; const title = document.getElementById('panelStatusTitle'); const labels = [document.getElementById('statusLabel1'), document.getElementById('statusLabel2'), document.getElementById('statusLabel3')]; const values = [document.getElementById('statusValue1'), document.getElementById('statusValue2'), document.getElementById('statusValue3')]; const dots = [...document.querySelectorAll('.status-glass-grid3 .status-dot')]; const metrics = statusPanelMetrics(summary, currentMode); title.textContent = currentMode === 'vehicle' ? dict.panelStatusVehicle : dict.panelStatusStation; const denominator = metrics.total; values.forEach((element, index) => { const pct = denominator ? metrics.counts[index] * 100 / denominator : 0; element.innerHTML = `${formatNumber(metrics.counts[index])} ${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 `
${escapeHTML(label)}
${escapeHTML(value)}
`; } 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 navigateToSelectedEntity() { const url = selectedEntity && navigationUrlForEntity(selectedEntity.mode, selectedEntity.entity); if (url) window.location.assign(url); } 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 operationalFields = operationalAccessGranted() ? [ 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, ...operationalFields].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 = `
${escapeHTML(node.name)}${escapeHTML(label)}
${(isVehicleNode || isStationPoint) && subline ? `
${escapeHTML(subline)}
` : ''}
`; const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name }); const infoWindow = new AMap.InfoWindow({ isCustom: true, content: `
${escapeHTML(node.name)}
${escapeHTML(node.detail || '')}
${isVehicleNode ? `
${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km
` : ''}
`, 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 = `
${escapeHTML(message)}
`; } } 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 = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; 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 : operationalAccessGranted() ? dict.rankByHydrogen : dict.authHydrogenRequired; document.querySelectorAll('.gtab').forEach(button => button.classList.toggle('active', button.id === (currentRankType === 'primary' ? 'rankPrimaryTab' : 'rankSecondaryTab'))); } function switchRankTab(type) { if (currentMode === 'station' && type === 'secondary' && !operationalAccessGranted()) { openAccessDialog('station', i18n[currentLang].authHydrogenRequired); return; } currentRankType = type; updateRankingControls(); renderRankingList(type); } function switchMode(mode, { skipAuthCheck = false } = {}) { if (mode === 'vehicle' && !skipAuthCheck && !operationalAccessGranted()) { openAccessDialog('vehicle', i18n[currentLang].authRequired); return; } 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'); updateAccessUI(); 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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[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); }