1774 lines
91 KiB
JavaScript
1774 lines
91 KiB
JavaScript
/** Lingniu Vehicle Map — live data from the vehicle open platform. */
|
||
|
||
const i18n = {
|
||
zh: {
|
||
vehicleModeTitle: '车辆网络', stationModeTitle: '加氢站网络',
|
||
kpiTotalFleet: '车辆总数', kpiTotalStation: '站点总数',
|
||
kpiOnlineFleet: '今日上线车辆', kpiCooperativeStation: '合作站点', kpiDailyMileage: '今日运营里程', kpiMonthlyHydrogen: '本月加氢量',
|
||
unitVehicle: '辆', unitVehicles: '辆', unitStation: '座', unitStations: '座', btnVehicle: '车辆', btnStation: '加氢站',
|
||
themeDark: '深色', themeLight: '浅色', legendPrimary: '核心节点 (>100)',
|
||
legendMid: '重点节点 (20-100)', legendLow: '普通节点 (<20)', btnResetView: '复位视角',
|
||
btn3dView: '3D/2D 视角', btnNationalView: '全国视图',
|
||
mapFooterGis: '地图引擎: 羚牛氢能 GIS (AMap 3D Engine)',
|
||
mapFooterStatus: '数据来源: 羚牛车辆数据开放平台', hintNational: '视角: 全国运营态势',
|
||
panelStatusVehicle: '车辆运营状态分布', panelStatusStation: '加氢站类型分布',
|
||
panelRankVehicle: '车辆区域分布 TOP 排名', panelRankStation: '加氢站省份 TOP 排名',
|
||
statusTotal: '总数', statusRunning: '行驶中', statusStopped: '静止中', statusOffline: '离线',
|
||
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationPartnerMarker: '合作',
|
||
rankByFleet: '按数量', rankByDist: '按里程', rankByStations: '按站点数', rankByHydrogen: '按加氢量', rankNoHydrogenData: '当前视野暂无加氢数据',
|
||
stationViewportEmpty: '当前视野暂无加氢站 · 可拖动地图或复位视角', viewportNow: '当前视野', viewportAll: '全部',
|
||
onlineText: '在线', unitTail: '台', 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 = `<span class="suggestion-empty">${escapeHTML(dict.searchEmpty)}</span>`;
|
||
return;
|
||
}
|
||
let html = '';
|
||
if (locations.length) {
|
||
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchLocations)}</span>`;
|
||
html += locations.map((item, index) => `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol is-location">⌖</span><span class="suggestion-copy"><strong>${escapeHTML(item.label)}</strong><small>${escapeHTML(item.path)}</small></span><span class="suggestion-kind">${escapeHTML(dict.suggestionLocation)}</span></button>`).join('');
|
||
}
|
||
if (entities.length) {
|
||
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchEntities)}</span>`;
|
||
html += entities.map((item, entityIndex) => {
|
||
const index = locations.length + entityIndex;
|
||
const isStation = item.mode === 'station';
|
||
const name = isStation ? item.entity.name || item.entity.shortName : item.entity.plateNumber || item.entity.vin;
|
||
const subline = isStation ? stationAdministrativePath(item.entity) : item.entity.vin;
|
||
return `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol">${isStation ? 'H₂' : '车'}</span><span class="suggestion-copy"><strong>${escapeHTML(name)}</strong><small>${escapeHTML(subline || '')}</small></span><span class="suggestion-kind">${escapeHTML(isStation ? dict.suggestionStation : dict.suggestionVehicle)}</span></button>`;
|
||
}).join('');
|
||
}
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
function openExploreSuggestions() {
|
||
const box = document.getElementById('exploreSuggestions');
|
||
if (!box) return;
|
||
box.hidden = false;
|
||
if (currentMode === 'vehicle' && !vehicleExploreCatalog.ready) {
|
||
void ensureVehicleExploreCatalog().then(() => renderExploreSuggestions());
|
||
}
|
||
renderExploreSuggestions();
|
||
}
|
||
|
||
function closeExploreSuggestions() {
|
||
const box = document.getElementById('exploreSuggestions');
|
||
if (box) box.hidden = true;
|
||
activeExploreSuggestionIndex = -1;
|
||
}
|
||
|
||
async function selectExploreLocation(option) {
|
||
filterState.query = '';
|
||
if (currentMode === 'station') {
|
||
filterState.province = option.province || '';
|
||
filterState.city = option.city || '';
|
||
filterState.district = option.district || '';
|
||
} else {
|
||
vehicleFilterGroups = {
|
||
provinces: vehicleExploreCatalog.provinces.length ? vehicleExploreCatalog.provinces : vehicleFilterGroups.provinces,
|
||
cities: vehicleExploreCatalog.cities.length ? vehicleExploreCatalog.cities : vehicleFilterGroups.cities,
|
||
districts: vehicleExploreCatalog.districts.length ? vehicleExploreCatalog.districts : vehicleFilterGroups.districts
|
||
};
|
||
filterState.province = option.provinceGroup?.adcode || '';
|
||
filterState.city = option.cityGroup?.adcode || '';
|
||
filterState.district = option.districtGroup?.adcode || '';
|
||
}
|
||
closeExploreSuggestions();
|
||
updateFilterToolbar();
|
||
focusCurrentRegion(option.level);
|
||
applyActiveFilters();
|
||
void syncFilterControls(dashboard);
|
||
}
|
||
|
||
function selectExploreEntity(item) {
|
||
const entity = item.entity;
|
||
if (!entity) return;
|
||
closeExploreSuggestions();
|
||
const coordinates = item.mode === 'vehicle'
|
||
? entity.locationAvailable ? wgs84ToGcj02(Number(entity.longitude), Number(entity.latitude)) : null
|
||
: [Number(entity.longitude), Number(entity.latitude)];
|
||
if (coordinates?.every(Number.isFinite)) map?.setZoomAndCenter(14, coordinates);
|
||
showEntityDetails(item.mode, entity);
|
||
}
|
||
|
||
function selectExploreSuggestion(index) {
|
||
const item = exploreSuggestionItems[index];
|
||
if (!item) return;
|
||
if (item.type === 'location') void selectExploreLocation(item);
|
||
else selectExploreEntity(item);
|
||
}
|
||
|
||
function handleExploreSearchKeydown(event) {
|
||
if (event.key === 'Escape') { closeExploreSuggestions(); event.currentTarget.blur(); return; }
|
||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return;
|
||
const box = document.getElementById('exploreSuggestions');
|
||
if (box?.hidden) openExploreSuggestions();
|
||
if (event.key === 'Enter' && activeExploreSuggestionIndex >= 0) {
|
||
event.preventDefault(); selectExploreSuggestion(activeExploreSuggestionIndex); return;
|
||
}
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||
activeExploreSuggestionIndex = (activeExploreSuggestionIndex + direction + exploreSuggestionItems.length) % Math.max(exploreSuggestionItems.length, 1);
|
||
renderExploreSuggestions();
|
||
}
|
||
}
|
||
|
||
function selectedLocationLabel(level) {
|
||
if (currentMode === 'station') {
|
||
const raw = filterState[level];
|
||
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
|
||
}
|
||
const group = level === 'province' ? vehicleFilterGroups.provinces.find(item => item.adcode === filterState.province)
|
||
: level === 'city' ? vehicleFilterGroups.cities.find(item => item.adcode === filterState.city)
|
||
: vehicleFilterGroups.districts.find(item => item.adcode === filterState.district);
|
||
return group ? groupLocationName(group, level) : '';
|
||
}
|
||
|
||
function renderFilterChips() {
|
||
const rail = document.getElementById('filterChipRail');
|
||
if (!rail) return;
|
||
const levels = ['province', 'city', 'district'].filter(level => filterState[level]);
|
||
rail.hidden = !levels.length;
|
||
rail.innerHTML = levels.map(level => `<span class="filter-chip">${escapeHTML(selectedLocationLabel(level))}<button type="button" onclick="clearLocationChip('${level}')" aria-label="清除${escapeHTML(selectedLocationLabel(level))}">×</button></span>`).join('');
|
||
}
|
||
|
||
function clearLocationChip(level) {
|
||
filterState[level] = '';
|
||
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
|
||
if (level === 'city') filterState.district = '';
|
||
void syncFilterControls(dashboard);
|
||
applyActiveFilters();
|
||
}
|
||
|
||
function updateFilterToolbar() {
|
||
const dict = i18n[currentLang];
|
||
const search = document.getElementById('entitySearch');
|
||
const searchClear = document.getElementById('searchClearBtn');
|
||
const meta = document.getElementById('filterResultMeta');
|
||
if (search) {
|
||
search.placeholder = dict.searchUniversal;
|
||
search.setAttribute('aria-label', dict.searchUniversal);
|
||
if (search.value !== filterState.query) search.value = filterState.query;
|
||
}
|
||
if (searchClear) searchClear.hidden = !normalizeSearch(filterState.query);
|
||
const all = currentMode === 'vehicle' ? dashboard?.vehicles?.length || 0 : dashboard?.stations?.length || 0;
|
||
const filtered = currentMode === 'vehicle' ? filteredVehicles().length : filteredStations().length;
|
||
if (meta) meta.textContent = isFilterActive() ? `${dict.filterResult} ${formatNumber(filtered)} / ${formatNumber(all)}` : `${dict.viewportAll} ${formatNumber(all)}`;
|
||
renderFilterChips();
|
||
renderExploreSuggestions();
|
||
}
|
||
|
||
function focusCoordinates(items, zoom) {
|
||
const located = items.filter(item => Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude)));
|
||
if (!map || !located.length) return;
|
||
const center = located.reduce((sum, item) => [sum[0] + Number(item.longitude), sum[1] + Number(item.latitude)], [0, 0]);
|
||
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
|
||
}
|
||
|
||
function focusVehicleSet(vehicles) {
|
||
const points = locatedVehicles(vehicles).map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
|
||
if (!map || !points.length) return;
|
||
if (points.length === 1) {
|
||
map.setZoomAndCenter(14, points[0]);
|
||
return;
|
||
}
|
||
const longitudes = points.map(point => point[0]);
|
||
const latitudes = points.map(point => point[1]);
|
||
const minLng = Math.min(...longitudes); const maxLng = Math.max(...longitudes);
|
||
const minLat = Math.min(...latitudes); const maxLat = Math.max(...latitudes);
|
||
const center = [(minLng + maxLng) / 2, (minLat + maxLat) / 2];
|
||
const latitudeScale = Math.cos(center[1] * Math.PI / 180);
|
||
const spanMeters = Math.max((maxLat - minLat) * 111320, (maxLng - minLng) * 111320 * latitudeScale, 400);
|
||
const size = map.getSize?.();
|
||
const width = Number(size?.getWidth?.()) || 900;
|
||
const height = Number(size?.getHeight?.()) || 560;
|
||
// Reserve room for the toolbar, detail card and map controls on every viewport.
|
||
const usablePixels = Math.max(180, Math.min(width * 0.72, height * 0.68));
|
||
const metersPerPixel = spanMeters / usablePixels;
|
||
const zoom = Math.max(7, Math.min(14, Math.log2((156543.03392 * latitudeScale) / metersPerPixel)));
|
||
map.setZoomAndCenter(zoom, center);
|
||
}
|
||
|
||
function focusCurrentRegion(level) {
|
||
if (!map) return;
|
||
if (currentMode === 'vehicle') {
|
||
const group = selectedVehicleFilterGroup();
|
||
if (level === 'district' && hasPinnedVehicleDistrictFilter() && locatedVehicles(filteredVehicles()).length) {
|
||
// District borders can be elongated. Fit the actual filtered fleet rather
|
||
// than centering on the administrative centroid, which can hide a valid car.
|
||
focusVehicleSet(filteredVehicles());
|
||
return;
|
||
}
|
||
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
|
||
return;
|
||
}
|
||
const stations = filteredStations();
|
||
if (stations.length) focusCoordinates(stations, level === 'province' ? 7.4 : level === 'city' ? 10.5 : 13);
|
||
}
|
||
|
||
function focusUniqueQueryResult() {
|
||
if (!normalizeSearch(filterState.query)) return;
|
||
const matches = currentMode === 'vehicle' ? filteredVehicles() : filteredStations();
|
||
if (matches.length === 1) {
|
||
const item = matches[0];
|
||
if (Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude))) {
|
||
const coordinates = currentMode === 'vehicle'
|
||
? wgs84ToGcj02(Number(item.longitude), Number(item.latitude))
|
||
: [Number(item.longitude), Number(item.latitude)];
|
||
map?.setZoomAndCenter(14, coordinates);
|
||
}
|
||
showEntityDetails(currentMode, item);
|
||
}
|
||
}
|
||
|
||
function applyActiveFilters({ focusQuery = false } = {}) {
|
||
closeEntityDetails();
|
||
updateFilterToolbar();
|
||
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||
else refreshStationViewport();
|
||
if (focusQuery) window.setTimeout(focusUniqueQueryResult, 0);
|
||
}
|
||
|
||
function handleExploreInput(value) {
|
||
filterState.query = value;
|
||
openExploreSuggestions();
|
||
updateFilterToolbar();
|
||
window.clearTimeout(filterDebounceTimer);
|
||
filterDebounceTimer = window.setTimeout(() => applyActiveFilters({ focusQuery: true }), 180);
|
||
}
|
||
|
||
function handleNameFilterInput(value) { handleExploreInput(value); }
|
||
|
||
function clearNameFilter() {
|
||
filterState.query = '';
|
||
closeExploreSuggestions();
|
||
applyActiveFilters();
|
||
}
|
||
|
||
async function handleRegionFilterChange(level, value) {
|
||
filterState[level] = value;
|
||
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
|
||
if (level === 'city') filterState.district = '';
|
||
await syncFilterControls(dashboard);
|
||
focusCurrentRegion(level);
|
||
applyActiveFilters();
|
||
}
|
||
|
||
function clearAllFilters() {
|
||
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
|
||
closeExploreSuggestions();
|
||
closeEntityDetails();
|
||
resetMapView();
|
||
void syncFilterControls(dashboard);
|
||
applyActiveFilters();
|
||
}
|
||
|
||
function initAMapInstance() {
|
||
if (typeof AMap === 'undefined') {
|
||
setDataState('error');
|
||
return;
|
||
}
|
||
map = new AMap.Map('amapContainer', {
|
||
zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30,
|
||
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
|
||
});
|
||
map.on('complete', () => { renderAMapMarkers(); renderUserLocationMarker(); });
|
||
map.on('zoomend', () => {
|
||
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||
else refreshStationViewport();
|
||
});
|
||
map.on('moveend', () => {
|
||
if (currentMode === 'vehicle' && hierarchyLevelForZoom() !== 'province') refreshVehicleRegionNodes(dashboard);
|
||
if (currentMode === 'station') refreshStationViewport();
|
||
});
|
||
}
|
||
|
||
function refreshStationViewport() {
|
||
updateRankingControls();
|
||
renderRankingList(currentRankType);
|
||
renderAMapMarkers();
|
||
}
|
||
|
||
function updateDashboardUI() {
|
||
if (!dashboard) return;
|
||
const dict = i18n[currentLang];
|
||
const summary = dashboard.summary;
|
||
const total = currentMode === 'vehicle' ? summary.totalVehicles : summary.totalStations;
|
||
const active = currentMode === 'vehicle' ? summary.onlineVehicles : summary.cooperativeStations;
|
||
const unit = countUnit(total, currentMode, dict);
|
||
const activeRate = total ? (active * 100 / total).toFixed(1) : '0.0';
|
||
const kpiLabels = modeKpiLabels(dict);
|
||
document.getElementById('dashboardTitle').textContent = currentMode === 'vehicle' ? dict.vehicleModeTitle : dict.stationModeTitle;
|
||
document.getElementById('kpiTotalLabel').textContent = kpiLabels.total;
|
||
document.getElementById('kpiActiveLabel').textContent = kpiLabels.active;
|
||
document.getElementById('kpiFleetTotal').innerHTML = `${formatNumber(total)} <small>${unit}</small>`;
|
||
document.getElementById('kpiFleetOnline').innerHTML = `${formatNumber(active)} <small>${countUnit(active, currentMode, dict)} (${activeRate}%)</small>`;
|
||
const activity = currentMode === 'vehicle'
|
||
? { label: dict.kpiDailyMileage, value: summary.todayMileageKm, unit: 'km' }
|
||
: 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 ? ` <small>${activity.unit}</small>` : ''}`
|
||
: `${formatNumber(activity.value, 1)} <small>${activity.unit}</small>`;
|
||
const regionSummary = buildVehicleRegionNodes();
|
||
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
|
||
updateStatusPanel();
|
||
updateRankingControls();
|
||
updateFilterToolbar();
|
||
renderRankingList(currentRankType);
|
||
renderAMapMarkers();
|
||
}
|
||
|
||
function mapStatusSummaryText(summary, regionSummary, asOf, mode = currentMode, lang = currentLang) {
|
||
if (mode === 'station') {
|
||
const cooperative = Number(summary.cooperativeStations || 0);
|
||
const external = Math.max(0, Number(summary.totalStations || 0) - cooperative);
|
||
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])} <small>${currentMode === 'vehicle' ? dict.unitVehicles : dict.unitStations}</small>`;
|
||
labels[index].textContent = dict[metrics.labelKeys[index]];
|
||
if (dots[index]) dots[index].className = `status-dot ${metrics.dotClasses[index]}`;
|
||
const pctElement = document.getElementById(`statusPct${index + 1}`);
|
||
if (pctElement) pctElement.textContent = `(${pct.toFixed(1)}%)`;
|
||
});
|
||
const segments = document.querySelectorAll('.liquid-progress-bar .seg');
|
||
if (segments.length) {
|
||
segments.forEach((segment, index) => segment.style.width = `${denominator ? metrics.progressCounts[index] * 100 / denominator : 0}%`);
|
||
}
|
||
document.getElementById('panelRankTitle').textContent = currentMode === 'vehicle' ? vehicleRankTitle(vehicleRegionSummary.level) : dict.panelRankStation;
|
||
}
|
||
|
||
function statusPanelMetrics(summary, mode = currentMode) {
|
||
const total = Number(mode === 'vehicle' ? summary.totalVehicles : summary.totalStations) || 0;
|
||
if (mode === 'vehicle') {
|
||
const driving = Number(summary.drivingVehicles) || 0;
|
||
const idle = Number(summary.idleVehicles) || 0;
|
||
return {
|
||
total, counts: [total, driving, idle],
|
||
labelKeys: ['statusTotal', 'statusRunning', 'statusStopped'],
|
||
dotClasses: ['dot-total', 'dot-mint', 'dot-blue'],
|
||
progressCounts: [driving, idle]
|
||
};
|
||
}
|
||
const cooperative = Number(summary.cooperativeStations) || 0;
|
||
const external = Math.max(0, total - cooperative);
|
||
return {
|
||
total, counts: [total, cooperative, external],
|
||
labelKeys: ['stationTotal', 'stationCooperative', 'stationExternal'],
|
||
dotClasses: ['dot-total', 'dot-mint', 'dot-blue'],
|
||
progressCounts: [cooperative, external]
|
||
};
|
||
}
|
||
|
||
function compactProvinceName(name) {
|
||
return String(name || '')
|
||
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
|
||
.replace(/特别行政区$/, '')
|
||
.replace(/[省市]$/, '');
|
||
}
|
||
|
||
function compactRegionName(name) {
|
||
return compactProvinceName(name)
|
||
.replace(/自治州$/, '')
|
||
.replace(/地区$/, '')
|
||
.replace(/林区$/, '')
|
||
.replace(/[盟区县市]$/, '');
|
||
}
|
||
|
||
function romanizeAdministrativeBase(value) {
|
||
const normalized = String(value || '').trim();
|
||
if (!normalized) return '';
|
||
if (ADMIN_ENGLISH_OVERRIDES[normalized]) return ADMIN_ENGLISH_OVERRIDES[normalized];
|
||
for (const [ethnicZh, ethnicEn] of Object.entries(ETHNIC_ENGLISH)) {
|
||
if (!normalized.endsWith(ethnicZh)) continue;
|
||
const prefix = normalized.slice(0, -ethnicZh.length);
|
||
const romanizedPrefix = romanizeAdministrativeBase(prefix);
|
||
return [romanizedPrefix, ethnicEn].filter(Boolean).join(' ');
|
||
}
|
||
const converter = globalThis.pinyinPro?.pinyin;
|
||
if (typeof converter !== 'function') return normalized;
|
||
const pinyin = converter(normalized, { toneType: 'none', separator: ' ' });
|
||
const joined = String(pinyin || '').replace(/[^A-Za-z0-9]+/g, '');
|
||
return joined ? joined.charAt(0).toUpperCase() + joined.slice(1).toLowerCase() : normalized;
|
||
}
|
||
|
||
function administrativeEnglishName(name, level = '') {
|
||
const normalized = String(name || '').trim();
|
||
if (!normalized || normalized === '[]') return '';
|
||
const compactProvince = compactProvinceName(normalized);
|
||
if (PROVINCE_ENGLISH[compactProvince] && (level === 'province' || /省|自治区|特别行政区$/.test(normalized) || MUNICIPALITIES.has(compactProvince))) {
|
||
return PROVINCE_ENGLISH[compactProvince];
|
||
}
|
||
const suffixEntry = ADMIN_SUFFIX_ENGLISH.find(([suffix]) => normalized.endsWith(suffix));
|
||
const suffix = suffixEntry?.[0] || '';
|
||
const base = suffix ? normalized.slice(0, -suffix.length) : normalized;
|
||
const translatedBase = ADMIN_ENGLISH_OVERRIDES[base] || romanizeAdministrativeBase(base);
|
||
return [translatedBase, suffixEntry?.[1]].filter(Boolean).join(' ');
|
||
}
|
||
|
||
function stationAdministrativePath(station, lang = currentLang) {
|
||
const levels = [
|
||
['province', station?.province], ['city', station?.city], ['district', station?.district]
|
||
].map(([level, name]) => lang === 'en' ? administrativeEnglishName(name, level) : String(name || '').trim())
|
||
.filter(name => name && name !== '[]');
|
||
return levels.filter((name, index) => index === 0 || name !== levels[index - 1]).join(' · ');
|
||
}
|
||
|
||
function hierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
|
||
if (hasPinnedVehicleDistrictFilter()) return 'vehicle';
|
||
if (zoom < REGION_ZOOM.city) return 'province';
|
||
if (zoom < REGION_ZOOM.district) return 'city';
|
||
if (zoom < REGION_ZOOM.vehicle) return 'district';
|
||
return 'vehicle';
|
||
}
|
||
|
||
function regionLoadingText(level, lang = currentLang) {
|
||
const zh = { province: '省级归属计算中', city: '市级归属计算中', district: '区县级归属计算中', vehicle: '单车定位计算中' };
|
||
const en = { province: 'province grouping in progress', city: 'city grouping in progress', district: 'district grouping in progress', vehicle: 'vehicle positioning in progress' };
|
||
return (lang === 'zh' ? zh : en)[level];
|
||
}
|
||
|
||
function regionSummaryText(summary, lang = currentLang) {
|
||
const zh = { province: '个省级行政区', city: '个当前视野城市', district: '个当前视野区县', vehicle: '辆当前视野车辆' };
|
||
const en = { province: ' province-level regions', city: ' cities in view', district: ' districts in view', vehicle: ' vehicles in view' };
|
||
return `${summary.nodes.length}${(lang === 'zh' ? zh : en)[summary.level]}`;
|
||
}
|
||
|
||
function vehicleRankTitle(level) {
|
||
if (currentLang === 'en') return ({ province: 'Top Provinces by Fleet', city: 'Top Cities by Fleet', district: 'Top Districts by Fleet', vehicle: 'Vehicles in Current View' })[level];
|
||
return ({ province: '省级车辆分布 TOP 排名', city: '市级车辆分布 TOP 排名', district: '区县级车辆分布 TOP 排名', vehicle: '当前视野车辆' })[level];
|
||
}
|
||
|
||
function loadAMapUIScript() {
|
||
const src = 'https://webapi.amap.com/ui/1.1/main.js?v=1.1.1';
|
||
if (window.AMapUI) return Promise.resolve();
|
||
return new Promise((resolve, reject) => {
|
||
const existing = document.querySelector?.(`script[src="${src}"]`);
|
||
if (existing) {
|
||
existing.addEventListener('load', resolve, { once: true });
|
||
existing.addEventListener('error', () => reject(new Error('高德行政区组件加载失败')), { once: true });
|
||
return;
|
||
}
|
||
const script = document.createElement('script');
|
||
script.src = src; script.async = true; script.onload = resolve;
|
||
script.onerror = () => reject(new Error('高德行政区组件加载失败'));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
function districtExplorer() {
|
||
if (districtExplorerPromise) return districtExplorerPromise;
|
||
districtExplorerPromise = loadAMapUIScript().then(() => new Promise((resolve) => {
|
||
window.AMapUI.loadUI(['geo/DistrictExplorer'], (DistrictExplorer) => {
|
||
resolve(new DistrictExplorer({ eventSupport: false }));
|
||
});
|
||
})).catch(error => {
|
||
districtExplorerPromise = null;
|
||
throw error;
|
||
});
|
||
return districtExplorerPromise;
|
||
}
|
||
|
||
function areaNodeFor(adcode) {
|
||
const key = String(adcode);
|
||
if (areaNodePromises.has(key)) return areaNodePromises.get(key);
|
||
const promise = districtExplorer().then(explorer => new Promise((resolve, reject) => {
|
||
explorer.loadAreaNode(Number(adcode), (error, areaNode) => {
|
||
if (error || !areaNode) reject(error || new Error(`行政区边界不可用: ${adcode}`));
|
||
else resolve(areaNode);
|
||
});
|
||
})).catch(error => {
|
||
areaNodePromises.delete(key);
|
||
throw error;
|
||
});
|
||
areaNodePromises.set(key, promise);
|
||
return promise;
|
||
}
|
||
|
||
const GCJ_A = 6378245;
|
||
const GCJ_EE = 0.006693421622965943;
|
||
function outsideGcjChina(longitude, latitude) { return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271; }
|
||
function transformLatitude(longitude, latitude) {
|
||
let value = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
|
||
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
||
value += (20 * Math.sin(latitude * Math.PI) + 40 * Math.sin(latitude / 3 * Math.PI)) * 2 / 3;
|
||
value += (160 * Math.sin(latitude / 12 * Math.PI) + 320 * Math.sin(latitude * Math.PI / 30)) * 2 / 3;
|
||
return value;
|
||
}
|
||
function transformLongitude(longitude, latitude) {
|
||
let value = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
|
||
value += (20 * Math.sin(6 * longitude * Math.PI) + 20 * Math.sin(2 * longitude * Math.PI)) * 2 / 3;
|
||
value += (20 * Math.sin(longitude * Math.PI) + 40 * Math.sin(longitude / 3 * Math.PI)) * 2 / 3;
|
||
value += (150 * Math.sin(longitude / 12 * Math.PI) + 300 * Math.sin(longitude / 30 * Math.PI)) * 2 / 3;
|
||
return value;
|
||
}
|
||
function wgs84ToGcj02(longitude, latitude) {
|
||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude) || outsideGcjChina(longitude, latitude)) return [longitude, latitude];
|
||
const latitudeOffset = transformLatitude(longitude - 105, latitude - 35);
|
||
const longitudeOffset = transformLongitude(longitude - 105, latitude - 35);
|
||
const radianLatitude = latitude / 180 * Math.PI;
|
||
const magic = 1 - GCJ_EE * Math.sin(radianLatitude) ** 2;
|
||
const sqrtMagic = Math.sqrt(magic);
|
||
return [
|
||
longitude + longitudeOffset * 180 / (GCJ_A / sqrtMagic * Math.cos(radianLatitude) * Math.PI),
|
||
latitude + latitudeOffset * 180 / ((GCJ_A * (1 - GCJ_EE)) / (magic * sqrtMagic) * Math.PI)
|
||
];
|
||
}
|
||
|
||
function locatedVehicles(vehicles = dashboard?.vehicles || []) {
|
||
return vehicles.filter(vehicle => vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude)));
|
||
}
|
||
|
||
function vehicleActiveToday(vehicle) {
|
||
return typeof vehicle?.activeToday === 'boolean' ? vehicle.activeToday : Boolean(vehicle?.online);
|
||
}
|
||
|
||
function partitionVehiclesByArea(areaNode, vehicles, level) {
|
||
const groups = [];
|
||
const unmatched = [];
|
||
for (const group of areaNode.groupByPosition(vehicles, vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)))) {
|
||
if (!group.points?.length) continue;
|
||
const feature = group.subFeature;
|
||
if (group.subFeatureIndex < 0 || !feature) { unmatched.push(...group.points); continue; }
|
||
const rawName = String(feature.properties.name || '');
|
||
const nameZh = level === 'province' ? compactProvinceName(rawName) : compactRegionName(rawName);
|
||
const positions = group.points.map(vehicle => wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)));
|
||
const center = feature.properties.center || [
|
||
positions.reduce((sum, point) => sum + point[0], 0) / positions.length,
|
||
positions.reduce((sum, point) => sum + point[1], 0) / positions.length
|
||
];
|
||
groups.push({ adcode: String(feature.properties.adcode), rawName, nameZh, lnglat: center, vehicles: group.points });
|
||
}
|
||
return { groups, unmatched };
|
||
}
|
||
|
||
function fallbackRegionGroup(parent, vehicles) {
|
||
return { ...parent, vehicles };
|
||
}
|
||
|
||
async function expandRegionGroups(parents, level) {
|
||
const expanded = await Promise.all(parents.map(async parent => {
|
||
if (level === 'city' && MUNICIPALITIES.has(compactProvinceName(parent.rawName))) {
|
||
return [{ ...parent, nameZh: compactProvinceName(parent.rawName) }];
|
||
}
|
||
try {
|
||
const areaNode = await areaNodeFor(parent.adcode);
|
||
const partition = partitionVehiclesByArea(areaNode, parent.vehicles, level);
|
||
if (partition.unmatched.length) partition.groups.push(fallbackRegionGroup(parent, partition.unmatched));
|
||
return partition.groups.length ? partition.groups : [parent];
|
||
} catch (error) {
|
||
console.warn(`region expansion failed for ${parent.adcode}`, error);
|
||
return [parent];
|
||
}
|
||
}));
|
||
return expanded.flat();
|
||
}
|
||
|
||
function regionNodesFromGroups(groups, level) {
|
||
return groups.map(group => ({
|
||
id: `vehicle-${level}-${group.adcode}`, kind: `vehicle${level.charAt(0).toUpperCase()}${level.slice(1)}`,
|
||
level, adcode: group.adcode, nameZh: group.nameZh,
|
||
nameEn: administrativeEnglishName(group.rawName || group.nameZh, level),
|
||
lnglat: group.lnglat, count: group.vehicles.length, online: group.vehicles.filter(vehicle => vehicleActiveToday(vehicle)).length,
|
||
dist: group.vehicles.reduce((sum, vehicle) => sum + Number(vehicle.dailyMileageKm || 0), 0), vehicles: group.vehicles,
|
||
detail: group.vehicles.slice(0, 5).map(vehicle => vehicle.plateNumber || vehicle.vin).join('、')
|
||
})).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN'));
|
||
}
|
||
|
||
async function aggregateVehicleHierarchy(level, vehicles = filteredVehicles()) {
|
||
const located = locatedVehicles(vehicles);
|
||
let unassigned = vehicles.length - located.length;
|
||
const nationalNode = await areaNodeFor(100000);
|
||
const provincePartition = partitionVehiclesByArea(nationalNode, located, 'province');
|
||
unassigned += provincePartition.unmatched.length;
|
||
if (level === 'province') return { level, nodes: regionNodesFromGroups(provincePartition.groups, level), unassigned, loading: false };
|
||
const cityGroups = await expandRegionGroups(provincePartition.groups, 'city');
|
||
if (level === 'city') return { level, nodes: regionNodesFromGroups(cityGroups, level), unassigned, loading: false };
|
||
const districtGroups = await expandRegionGroups(cityGroups, 'district');
|
||
return { level, nodes: regionNodesFromGroups(districtGroups, level), unassigned, loading: false };
|
||
}
|
||
|
||
function currentMapBounds() {
|
||
const bounds = map?.getBounds?.();
|
||
const southWest = bounds?.getSouthWest?.();
|
||
const northEast = bounds?.getNorthEast?.();
|
||
const west = southWest?.getLng?.(); const south = southWest?.getLat?.();
|
||
const east = northEast?.getLng?.(); const north = northEast?.getLat?.();
|
||
if (![west, south, east, north].every(Number.isFinite)) return null;
|
||
return { west, south, east, north };
|
||
}
|
||
|
||
function vehiclePointNodes(vehicles = filteredVehicles()) {
|
||
const bounds = currentMapBounds();
|
||
return locatedVehicles(vehicles).map(vehicle => {
|
||
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
|
||
return { vehicle, lnglat };
|
||
}).filter(({ lnglat }) => hasPinnedVehicleDistrictFilter() || !bounds || (lnglat[0] >= bounds.west && lnglat[0] <= bounds.east && lnglat[1] >= bounds.south && lnglat[1] <= bounds.north))
|
||
.map(({ vehicle, lnglat }) => ({
|
||
id: `vehicle-point-${vehicle.vin}`, kind: 'vehiclePoint', level: 'vehicle',
|
||
nameZh: vehicle.plateNumber || vehicle.vin, nameEn: vehicle.plateNumber || vehicle.vin,
|
||
lnglat, count: 1, online: vehicleActiveToday(vehicle) ? 1 : 0, dist: Number(vehicle.dailyMileageKm || 0), vehicles: [vehicle],
|
||
speedKmh: Number(vehicle.speedKmh || 0), protocol: vehicle.protocol || '',
|
||
detail: [vehicle.vin, vehicle.protocol, vehicle.recordTime].filter(Boolean).join(' · ')
|
||
}));
|
||
}
|
||
|
||
function nodesInCurrentView(nodes, level) {
|
||
if (level === 'province') return nodes;
|
||
const bounds = currentMapBounds();
|
||
if (!bounds) return nodes;
|
||
return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north);
|
||
}
|
||
|
||
async function refreshVehicleRegionNodes(sourceDashboard = dashboard) {
|
||
if (!sourceDashboard) return;
|
||
const level = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
|
||
const version = ++regionAggregationVersion;
|
||
const vehicles = filteredVehicles(sourceDashboard.vehicles || []);
|
||
const unassigned = vehicles.length - locatedVehicles(vehicles).length;
|
||
if (level === 'vehicle') {
|
||
vehicleRegionSummary = { level, nodes: vehiclePointNodes(vehicles), unassigned, loading: false };
|
||
updateDashboardUI();
|
||
return;
|
||
}
|
||
vehicleRegionSummary = { level, nodes: [], unassigned, loading: true };
|
||
updateDashboardUI();
|
||
try {
|
||
const result = await aggregateVehicleHierarchy(level, vehicles);
|
||
const currentLevel = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
|
||
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== currentLevel) return;
|
||
result.nodes = nodesInCurrentView(result.nodes, level);
|
||
vehicleRegionSummary = result;
|
||
} catch (error) {
|
||
console.error('vehicle region aggregation failed', error);
|
||
if (version !== regionAggregationVersion) return;
|
||
vehicleRegionSummary = { level, nodes: [], unassigned: sourceDashboard.vehicles?.length || 0, loading: false };
|
||
}
|
||
updateDashboardUI();
|
||
}
|
||
|
||
function buildVehicleRegionNodes() {
|
||
return {
|
||
...vehicleRegionSummary,
|
||
nodes: vehicleRegionSummary.nodes.map(node => ({ ...node, name: currentLang === 'zh' ? node.nameZh : node.nameEn }))
|
||
};
|
||
}
|
||
|
||
function vehicleNodes() { return buildVehicleRegionNodes().nodes; }
|
||
|
||
function stationHierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
|
||
if (normalizeSearch(filterState.query)) return 'station';
|
||
if (zoom < 7) return 'province';
|
||
if (zoom < 11) return 'city';
|
||
return 'station';
|
||
}
|
||
|
||
function stationGroupName(station, level) {
|
||
const raw = level === 'province' ? station.province : station.city;
|
||
if (!raw) return currentLang === 'zh' ? '未标注区域' : 'Unspecified';
|
||
if (currentLang === 'en') return administrativeEnglishName(raw, level) || raw;
|
||
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
|
||
}
|
||
|
||
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevelForZoom()) {
|
||
if (level === 'station') {
|
||
return stations.map(station => ({
|
||
id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude],
|
||
station,
|
||
count: 1, online: station.cooperative ? 1 : 0,
|
||
adminPath: [stationAdministrativePath(station), stationDistrictName(station)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · '),
|
||
detail: [stationAdministrativePath(station), currentLang === 'zh' ? station.address : ''].filter(Boolean).join(' · '),
|
||
cooperative: station.cooperative, hydrogenKg: Number(station.totalHydrogenKg) || 0,
|
||
hasHydrogen: station.totalHydrogenKg != null
|
||
}));
|
||
}
|
||
const grouped = new Map();
|
||
for (const station of stations) {
|
||
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
|
||
if (!grouped.has(key)) grouped.set(key, {
|
||
kind: level === 'province' ? 'stationProvince' : 'stationCity', name: stationGroupName(station, level),
|
||
lng: 0, lat: 0, count: 0, online: 0, hydrogenKg: 0, hasHydrogen: false, stations: []
|
||
});
|
||
const group = grouped.get(key);
|
||
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1;
|
||
group.online += station.cooperative ? 1 : 0; group.stations.push(station);
|
||
if (station.totalHydrogenKg != null) {
|
||
group.hasHydrogen = true;
|
||
group.hydrogenKg += Number(station.totalHydrogenKg) || 0;
|
||
}
|
||
}
|
||
return [...grouped.values()].map((group, index) => ({
|
||
...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count],
|
||
detail: group.stations.slice(0, 5).map(station => station.name).join('、')
|
||
}));
|
||
}
|
||
|
||
function nodesInsideCurrentMapBounds(nodes) {
|
||
const bounds = currentMapBounds();
|
||
if (!bounds) return nodes;
|
||
return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north);
|
||
}
|
||
|
||
function stationNodes(visibleOnly = false) {
|
||
const nodes = buildStationNodes();
|
||
return visibleOnly ? nodesInsideCurrentMapBounds(nodes) : nodes;
|
||
}
|
||
|
||
function stationViewportSummary() {
|
||
const level = stationHierarchyLevelForZoom();
|
||
const allNodes = stationNodes(false);
|
||
const visibleNodes = nodesInsideCurrentMapBounds(allNodes);
|
||
return { level, allNodes, visibleNodes };
|
||
}
|
||
|
||
function countUnit(count, resource, dict = i18n[currentLang]) {
|
||
const isEnglish = dict === i18n.en;
|
||
if (resource === 'vehicle') return isEnglish && Number(count) === 1 ? dict.unitVehicle : dict.unitVehicles;
|
||
return isEnglish && Number(count) === 1 ? dict.unitStation : dict.unitStations;
|
||
}
|
||
|
||
function countLabel(count, resource, dict = i18n[currentLang]) {
|
||
return `${formatNumber(count)} ${countUnit(count, resource, dict)}`;
|
||
}
|
||
|
||
function markerLabel(node, dict = i18n[currentLang]) {
|
||
if (node.kind === 'station') return node.cooperative ? `H₂ · ${dict.stationPartnerMarker}` : 'H₂';
|
||
if (node.kind === 'stationProvince' || node.kind === 'stationCity' || node.kind === 'stationCluster') return countLabel(node.count, 'station', dict);
|
||
if (node.kind === 'vehiclePoint') return node.online ? dict.onlineText : dict.statusOffline;
|
||
return countLabel(node.count, 'vehicle', dict);
|
||
}
|
||
|
||
function entityKey(mode, entity) {
|
||
return mode === 'vehicle' ? `vehicle:${entity?.vin || ''}` : `station:${entity?.id || ''}`;
|
||
}
|
||
|
||
function nodeEntity(node) {
|
||
if (node?.kind === 'vehiclePoint') return { mode: 'vehicle', entity: node.vehicle || node.vehicles?.[0] };
|
||
if (node?.kind === 'station') return { mode: 'station', entity: node.station };
|
||
return null;
|
||
}
|
||
|
||
function isNodeSelected(node) {
|
||
const target = nodeEntity(node);
|
||
return Boolean(target?.entity && selectedEntity?.key === entityKey(target.mode, target.entity));
|
||
}
|
||
|
||
function detailValue(value, suffix = '') {
|
||
if (value == null || value === '') return i18n[currentLang].valueUnavailable;
|
||
return `${value}${suffix}`;
|
||
}
|
||
|
||
function detailField(label, value, wide = false, extra = false) {
|
||
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
|
||
}
|
||
|
||
function vehicleStatusLabel(vehicle) {
|
||
const status = vehicleActiveToday(vehicle)
|
||
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
|
||
: i18n[currentLang].statusOffline;
|
||
return status;
|
||
}
|
||
|
||
function navigationCoordinates(mode, entity) {
|
||
const longitude = Number(entity?.longitude);
|
||
const latitude = Number(entity?.latitude);
|
||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||
// Vehicle telemetry is WGS-84 and is converted before rendering on AMap.
|
||
// Station assets already use AMap (GCJ-02) coordinates.
|
||
return mode === 'vehicle' ? wgs84ToGcj02(longitude, latitude) : [longitude, latitude];
|
||
}
|
||
|
||
function navigationUrlForEntity(mode, entity) {
|
||
const coordinates = navigationCoordinates(mode, entity);
|
||
if (!coordinates?.every(Number.isFinite)) return null;
|
||
const name = mode === 'station'
|
||
? entity?.name || entity?.shortName || i18n[currentLang].detailStation
|
||
: entity?.plateNumber || entity?.vin || i18n[currentLang].detailVehicle;
|
||
const [longitude, latitude] = coordinates.map(value => Number(value).toFixed(6));
|
||
return `https://uri.amap.com/navigation?to=${longitude},${latitude},${encodeURIComponent(name)}&mode=car&coordinate=gaode&callnative=1`;
|
||
}
|
||
|
||
function updateDetailNavigation(mode, entity) {
|
||
const button = document.getElementById('detailNavigateBtn');
|
||
if (!button) return;
|
||
const url = navigationUrlForEntity(mode, entity);
|
||
const label = i18n[currentLang].detailNavigate;
|
||
button.hidden = !url;
|
||
button.disabled = !url;
|
||
button.dataset.navigationUrl = url || '';
|
||
button.setAttribute('aria-label', label);
|
||
button.title = label;
|
||
}
|
||
|
||
function 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 = `<div class="province-info-card${node.kind === 'vehiclePoint' ? ` is-vehicle-point ${node.online ? 'is-online' : 'is-offline'}` : ''}${isNodeSelected(node) ? ' is-selected' : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${(isVehicleNode || isStationPoint) && subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||
const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name });
|
||
const infoWindow = new AMap.InfoWindow({
|
||
isCustom: true,
|
||
content: `<div class="map-info-window"><strong>${escapeHTML(node.name)}</strong><div>${escapeHTML(node.detail || '')}</div>${isVehicleNode ? `<div>${dict.onlineText}: ${node.online}/${node.count} · ${formatNumber(node.dist, 1)} km</div>` : ''}</div>`,
|
||
offset: new AMap.Pixel(0, -32)
|
||
});
|
||
infoWindowList.push(infoWindow);
|
||
marker.on('mouseover', () => infoWindow.open(map, node.lnglat));
|
||
marker.on('mouseout', () => infoWindow.close());
|
||
if (node.kind === 'vehicleProvince') marker.on('click', () => map.setZoomAndCenter(7.4, node.lnglat));
|
||
if (node.kind === 'vehicleCity') marker.on('click', () => map.setZoomAndCenter(9.8, node.lnglat));
|
||
if (node.kind === 'vehicleDistrict') marker.on('click', () => map.setZoomAndCenter(12.2, node.lnglat));
|
||
if (node.kind === 'stationProvince' || node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
|
||
if (node.kind === 'stationCity') marker.on('click', () => map.setZoomAndCenter(11.2, node.lnglat));
|
||
if (node.kind === 'vehiclePoint' || node.kind === 'station') marker.on('click', () => showNodeDetails(node));
|
||
marker.setMap(map); markerList.push(marker);
|
||
}
|
||
}
|
||
|
||
function renderRankingList(type) {
|
||
const box = document.getElementById('rankingListBox');
|
||
if (!box || !dashboard) return;
|
||
box.innerHTML = '';
|
||
let rows;
|
||
if (currentMode === 'vehicle') {
|
||
if (normalizeSearch(filterState.query)) {
|
||
rows = filteredVehicles()
|
||
.sort((left, right) => Number(vehicleActiveToday(right)) - Number(vehicleActiveToday(left)) || String(left.plateNumber || left.vin).localeCompare(String(right.plateNumber || right.vin), 'zh-CN'))
|
||
.map(vehicle => {
|
||
const hasLocation = vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude));
|
||
const location = hasLocation ? wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)) : null;
|
||
return {
|
||
name: vehicle.plateNumber || vehicle.vin,
|
||
value: userLocation && location ? `${formatNumber(distanceKm(userLocation, location), 1)} km` : vehicleStatusLabel(vehicle),
|
||
location, zoom: 14, entity: vehicle, mode: 'vehicle'
|
||
};
|
||
});
|
||
} else {
|
||
rows = buildVehicleRegionNodes().nodes
|
||
.sort((a, b) => userLocation && a.kind === 'vehiclePoint'
|
||
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
|
||
: type === 'secondary' ? b.dist - a.dist : b.count - a.count)
|
||
.slice(0, 100)
|
||
.map(item => ({
|
||
name: item.name,
|
||
value: userLocation && item.kind === 'vehiclePoint' ? `${formatNumber(distanceKm(userLocation, item.lnglat), 1)} km`
|
||
: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
|
||
location: item.lnglat,
|
||
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14,
|
||
node: item, entity: item.kind === 'vehiclePoint' ? item.vehicle || item.vehicles?.[0] : null, mode: 'vehicle'
|
||
}));
|
||
}
|
||
} else {
|
||
const { level, allNodes, visibleNodes } = stationViewportSummary();
|
||
const byHydrogen = type === 'secondary';
|
||
rows = [...visibleNodes]
|
||
.filter(node => !byHydrogen || node.hasHydrogen)
|
||
.sort((a, b) => userLocation && level === 'station'
|
||
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
|
||
: byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
|
||
.map(node => ({
|
||
name: node.name,
|
||
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
|
||
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
|
||
location: node.lnglat,
|
||
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
|
||
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
|
||
}));
|
||
updateStationViewportMeta(visibleNodes.length, allNodes.length);
|
||
if (!rows.length) {
|
||
const message = byHydrogen && visibleNodes.length ? i18n[currentLang].rankNoHydrogenData : i18n[currentLang].stationViewportEmpty;
|
||
box.innerHTML = `<div class="rank-empty rank-empty-viewport"><span class="rank-empty-icon">⌖</span><span>${escapeHTML(message)}</span></div>`;
|
||
}
|
||
}
|
||
rows.forEach((item, index) => {
|
||
const row = document.createElement('div');
|
||
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
|
||
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
|
||
row.onclick = () => {
|
||
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
|
||
if (item.entity) showEntityDetails(item.mode, item.entity);
|
||
};
|
||
box.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function updateStationViewportMeta(visibleCount, totalCount) {
|
||
const meta = document.getElementById('rankViewportMeta');
|
||
if (!meta) return;
|
||
if (currentMode !== 'station') {
|
||
meta.hidden = true;
|
||
return;
|
||
}
|
||
const dict = i18n[currentLang];
|
||
meta.hidden = false;
|
||
meta.textContent = `${dict.viewportNow} ${formatNumber(visibleCount)} · ${dict.viewportAll} ${formatNumber(totalCount)}`;
|
||
}
|
||
|
||
function updateRankingControls() {
|
||
const dict = i18n[currentLang];
|
||
const stationLevel = stationHierarchyLevelForZoom();
|
||
const title = userLocation && ((currentMode === 'vehicle' && vehicleRegionSummary.level === 'vehicle') || (currentMode === 'station' && stationLevel === 'station'))
|
||
? (currentMode === 'vehicle' ? dict.nearbyVehicles : dict.nearbyStations)
|
||
: currentMode === 'vehicle'
|
||
? dict.panelRankVehicle
|
||
: stationLevel === 'province' ? dict.panelRankStation : stationLevel === 'city'
|
||
? (currentLang === 'zh' ? '加氢站城市 TOP 排名' : 'Top Cities by Stations')
|
||
: (currentLang === 'zh' ? '加氢站 TOP 排名' : 'Top H₂ Stations');
|
||
document.getElementById('panelRankTitle').textContent = title;
|
||
if (currentMode === 'vehicle') updateStationViewportMeta(0, 0);
|
||
document.getElementById('rankPrimaryTab').textContent = currentMode === 'vehicle' ? dict.rankByFleet : dict.rankByStations;
|
||
document.getElementById('rankSecondaryTab').textContent = currentMode === 'vehicle'
|
||
? dict.rankByDist
|
||
: 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);
|
||
}
|