feat(map): streamline search and detail panels

This commit is contained in:
lingniu
2026-08-11 17:41:45 +08:00
parent c42570c0ee
commit f535b1570b
4 changed files with 1415 additions and 32 deletions
+796 -29
View File
@@ -17,7 +17,16 @@ const i18n = {
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationPartnerMarker: '合作',
rankByFleet: '按数量', rankByDist: '按里程', rankByStations: '按站点数', rankByHydrogen: '按加氢量', rankNoHydrogenData: '当前视野暂无加氢数据',
stationViewportEmpty: '当前视野暂无加氢站 · 可拖动地图或复位视角', viewportNow: '当前视野', viewportAll: '全部',
onlineText: '在线', unitTail: '台'
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: '累计加氢量',
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象',
suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息'
},
en: {
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
@@ -35,7 +44,16 @@ const i18n = {
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'
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',
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'
}
};
@@ -80,6 +98,17 @@ 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: '' };
const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 };
const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
@@ -91,6 +120,10 @@ document.addEventListener('DOMContentLoaded', () => {
refreshTimer = window.setInterval(loadDashboard, 15000);
});
document.addEventListener('pointerdown', event => {
if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions();
});
async function loadDashboard() {
setDataState('loading');
try {
@@ -98,6 +131,7 @@ async function loadDashboard() {
const payload = await response.json();
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
dashboard = payload;
void syncFilterControls(payload);
updateDashboardUI();
refreshVehicleRegionNodes(payload);
setDataState('ready');
@@ -114,6 +148,498 @@ function setDataState(state) {
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
}
function normalizeSearch(value) {
return String(value || '').trim().toLocaleLowerCase();
}
function stationDistrictName(station) {
if (station?.district) return String(station.district).trim();
let address = String(station?.address || '').trim();
for (const prefix of [station?.province, station?.city]) {
const normalized = String(prefix || '').trim();
if (normalized) address = address.split(normalized).join('');
}
return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || '';
}
function vehicleMatchesQuery(vehicle, query = filterState.query) {
const needle = normalizeSearch(query);
if (!needle) return true;
return [vehicle?.plateNumber, vehicle?.vin].some(value => normalizeSearch(value).includes(needle));
}
function stationMatchesQuery(station, query = filterState.query) {
const needle = normalizeSearch(query);
if (!needle) return true;
return [station?.name, station?.shortName, station?.address].some(value => normalizeSearch(value).includes(needle));
}
function selectedVehicleFilterGroup() {
if (filterState.district) return vehicleFilterGroups.districts.find(group => group.adcode === filterState.district) || null;
if (filterState.city) return vehicleFilterGroups.cities.find(group => group.adcode === filterState.city) || null;
if (filterState.province) return vehicleFilterGroups.provinces.find(group => group.adcode === filterState.province) || null;
return null;
}
function 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 focusCurrentRegion(level) {
if (!map) return;
if (currentMode === 'vehicle') {
const group = selectedVehicleFilterGroup();
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');
@@ -123,7 +649,7 @@ function initAMapInstance() {
zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30,
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
});
map.on('complete', renderAMapMarkers);
map.on('complete', () => { renderAMapMarkers(); renderUserLocationMarker(); });
map.on('zoomend', () => {
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
@@ -165,6 +691,7 @@ function updateDashboardUI() {
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
updateStatusPanel();
updateRankingControls();
updateFilterToolbar();
renderRankingList(currentRankType);
renderAMapMarkers();
}
@@ -450,7 +977,7 @@ function regionNodesFromGroups(groups, level) {
})).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN'));
}
async function aggregateVehicleHierarchy(level, vehicles = dashboard?.vehicles || []) {
async function aggregateVehicleHierarchy(level, vehicles = filteredVehicles()) {
const located = locatedVehicles(vehicles);
let unassigned = vehicles.length - located.length;
const nationalNode = await areaNodeFor(100000);
@@ -473,7 +1000,7 @@ function currentMapBounds() {
return { west, south, east, north };
}
function vehiclePointNodes(vehicles = dashboard?.vehicles || []) {
function vehiclePointNodes(vehicles = filteredVehicles()) {
const bounds = currentMapBounds();
return locatedVehicles(vehicles).map(vehicle => {
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
@@ -497,19 +1024,21 @@ function nodesInCurrentView(nodes, level) {
async function refreshVehicleRegionNodes(sourceDashboard = dashboard) {
if (!sourceDashboard) return;
const level = hierarchyLevelForZoom();
const level = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
const version = ++regionAggregationVersion;
const unassigned = (sourceDashboard.vehicles || []).length - locatedVehicles(sourceDashboard.vehicles || []).length;
const vehicles = filteredVehicles(sourceDashboard.vehicles || []);
const unassigned = vehicles.length - locatedVehicles(vehicles).length;
if (level === 'vehicle') {
vehicleRegionSummary = { level, nodes: vehiclePointNodes(sourceDashboard.vehicles || []), unassigned, loading: false };
vehicleRegionSummary = { level, nodes: vehiclePointNodes(vehicles), unassigned, loading: false };
updateDashboardUI();
return;
}
vehicleRegionSummary = { level, nodes: [], unassigned, loading: true };
updateDashboardUI();
try {
const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []);
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return;
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) {
@@ -530,6 +1059,7 @@ function buildVehicleRegionNodes() {
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';
@@ -542,12 +1072,13 @@ function stationGroupName(station, level) {
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
}
function buildStationNodes(stations = dashboard?.stations || [], level = stationHierarchyLevelForZoom()) {
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),
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
@@ -609,6 +1140,126 @@ function markerLabel(node, dict = i18n[currentLang]) {
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 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(' · ');
grid.innerHTML = [
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
detailField(dict.detailAdmin, detailValue(admin)),
detailField(dict.detailAddress, detailValue(entity.address), true),
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),
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
].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;
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 = [];
@@ -622,7 +1273,7 @@ function renderAMapMarkers() {
markerContent.className = 'province-info-badge-wrap';
const label = markerLabel(node, dict);
const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : isStationPoint ? node.adminPath : `${dict.onlineText} ${node.online}`;
markerContent.innerHTML = `<div class="province-info-card${node.kind === 'vehiclePoint' ? ` is-vehicle-point ${node.online ? 'is-online' : 'is-offline'}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${(isVehicleNode || isStationPoint) && subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
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,
@@ -637,6 +1288,7 @@ function renderAMapMarkers() {
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);
}
}
@@ -647,26 +1299,48 @@ function renderRankingList(type) {
box.innerHTML = '';
let rows;
if (currentMode === 'vehicle') {
rows = buildVehicleRegionNodes().nodes
.sort((a, b) => type === 'secondary' ? b.dist - a.dist : b.count - a.count)
.slice(0, 12)
.map(item => ({
name: item.name,
value: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
location: item.lnglat,
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14
}));
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) => byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
.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: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
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
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) {
@@ -675,9 +1349,13 @@ function renderRankingList(type) {
}
}
rows.forEach((item, index) => {
const row = document.createElement('div'); row.className = 'rank-glass-row';
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>`;
if (item.location) row.onclick = () => map?.setZoomAndCenter(item.zoom || 9, item.location);
row.onclick = () => {
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
if (item.entity) showEntityDetails(item.mode, item.entity);
};
box.appendChild(row);
});
}
@@ -697,7 +1375,9 @@ function updateStationViewportMeta(visibleCount, totalCount) {
function updateRankingControls() {
const dict = i18n[currentLang];
const stationLevel = stationHierarchyLevelForZoom();
const title = currentMode === 'vehicle'
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')
@@ -717,15 +1397,23 @@ function switchRankTab(type) {
function switchMode(mode) {
currentMode = mode;
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
closeExploreSuggestions();
selectedEntity = null;
const detailCard = document.getElementById('mapDetailCard');
if (detailCard) detailCard.hidden = true;
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
currentRankType = 'primary';
void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle');
updateDashboardUI();
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
}
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];
@@ -740,7 +1428,10 @@ function setLanguage(lang) {
brandLogoEnglish.hidden = !english;
brandLogo.alt = '羚牛氢能';
}
void syncFilterControls(dashboard);
updateLocateButton(userLocation ? 'active' : 'idle');
updateDashboardUI();
if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity);
}
function setTheme(themeName) {
@@ -752,8 +1443,84 @@ function setTheme(themeName) {
function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; }
function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } }
function clearProvinceFilter() { resetMapView(); }
function clearProvinceFilter() { clearAllFilters(); }
function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } }
function locationCoordinates(position) {
const longitude = Number(position?.coords?.longitude);
const latitude = Number(position?.coords?.latitude);
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
return wgs84ToGcj02(longitude, latitude);
}
function updateLocateButton(state) {
const button = document.getElementById('locateDeviceBtn');
const label = document.getElementById('locateDeviceLabel');
if (!button || !label) return;
const dict = i18n[currentLang];
button.classList.toggle('is-loading', state === 'loading');
button.classList.toggle('is-active', state === 'active');
button.classList.toggle('is-error', state === 'error');
button.disabled = state === 'loading';
label.textContent = state === 'loading' ? dict.locating : state === 'active' ? dict.located : state === 'error' ? dict.locateFailed : dict.btnLocate;
}
function renderUserLocationMarker() {
if (!map || !userLocation || typeof AMap === 'undefined') return;
if (userLocationMarker) userLocationMarker.remove();
const content = document.createElement('div'); content.className = 'user-location-marker';
userLocationMarker = new AMap.Marker({
position: userLocation,
content,
offset: new AMap.Pixel(-9, -9),
title: currentLang === 'zh' ? '我的位置' : 'My location',
zIndex: 300
});
userLocationMarker.setMap(map);
}
function handleLocationSuccess(position) {
const coordinates = locationCoordinates(position);
if (!coordinates) { handleLocationError(); return; }
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
selectedEntity = null;
const detailCard = document.getElementById('mapDetailCard');
if (detailCard) detailCard.hidden = true;
void syncFilterControls(dashboard);
const feedback = document.getElementById('locationFeedback');
if (feedback) feedback.hidden = true;
userLocation = coordinates;
updateLocateButton('active');
renderUserLocationMarker();
map?.setZoomAndCenter(13, coordinates);
const hint = document.getElementById('selectedRegionHint');
if (hint) hint.textContent = currentLang === 'zh' ? '视角: 我的位置附近' : 'View: Near my location';
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
else refreshStationViewport();
}
function handleLocationError(error) {
const dict = i18n[currentLang];
const feedback = document.getElementById('locationFeedback');
if (feedback) {
feedback.textContent = error?.code === 1 ? dict.locatePermissionHint : error?.code === 3 ? dict.locateTimeoutHint : dict.locateUnavailableHint;
feedback.hidden = false;
window.setTimeout(() => { feedback.hidden = true; }, 5200);
}
updateLocateButton('error');
window.setTimeout(() => updateLocateButton(userLocation ? 'active' : 'idle'), 2400);
}
function locateDevice() {
if (!navigator.geolocation) { handleLocationError(); return; }
updateLocateButton('loading');
navigator.geolocation.getCurrentPosition(handleLocationSuccess, handleLocationError, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 30000
});
}
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function initClock() {