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
+791 -24
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') {
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) => type === 'secondary' ? b.dist - a.dist : b.count - a.count)
.slice(0, 12)
.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: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
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
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() {
+40 -2
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css?v=20260804044000">
<link rel="stylesheet" href="styles.css?v=20260812113000">
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
<script type="text/javascript">
@@ -114,10 +114,48 @@
</div>
<div class="map-explore-toolbar" aria-label="地图筛选与定位">
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateDevice()" aria-label="定位到我的位置" title="定位到我的位置">
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3"></circle>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path>
<circle cx="12" cy="12" r="8"></circle>
</svg>
<span id="locateDeviceLabel" data-i18n="btnLocate">定位</span>
</button>
<div class="explore-search-shell">
<label class="entity-search-field" for="entitySearch">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg>
<input id="entitySearch" type="search" autocomplete="off" placeholder="搜索位置、站点、车牌或 VIN" aria-label="搜索位置、站点、车牌或 VIN" oninput="handleExploreInput(this.value)" onfocus="openExploreSuggestions()" onkeydown="handleExploreSearchKeydown(event)">
<button class="search-clear-btn" id="searchClearBtn" type="button" onclick="clearNameFilter()" aria-label="清除名称筛选" hidden>×</button>
</label>
<div class="explore-suggestions" id="exploreSuggestions" role="listbox" aria-label="搜索建议" hidden></div>
</div>
<div class="filter-chip-rail" id="filterChipRail" aria-label="当前筛选" hidden></div>
<span class="filter-result-meta" id="filterResultMeta" aria-live="polite">全部 0</span>
<span class="location-feedback" id="locationFeedback" role="status" hidden></span>
</div>
<!-- AMap 3D Canvas -->
<div class="amap-canvas-box">
<div id="amapContainer"></div>
<article class="map-detail-card" id="mapDetailCard" aria-live="polite" hidden>
<div class="detail-card-accent" id="detailCardAccent"></div>
<div class="detail-card-header">
<div class="detail-title-wrap">
<span class="detail-type-pill" id="detailTypePill">车辆</span>
<h2 id="detailTitle"></h2>
<p id="detailSubtitle"></p>
</div>
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
</div>
<dl class="detail-grid" id="detailGrid"></dl>
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
</article>
<!-- Bottom Right Floating Controls -->
<div class="map-action-controls">
<button class="glass-btn" onclick="resetMapView()" data-i18n="btnResetView">复位视角</button>
@@ -191,6 +229,6 @@
</div>
<script src="node_modules/pinyin-pro/dist/index.js?v=3.28.2"></script>
<script src="app.js?v=20260811170000"></script>
<script src="app.js?v=20260812113000"></script>
</body>
</html>
+559
View File
@@ -334,6 +334,335 @@ body {
.map-top-bar * { pointer-events: auto; }
.map-explore-toolbar {
position: absolute;
top: 48px;
left: 12px;
max-width: calc(100% - 24px);
z-index: 12;
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 5px;
border: 1px solid var(--glass-border);
border-radius: 13px;
background: var(--glass-bg);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
.locate-btn,
.filter-clear-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
width: 32px;
min-height: 32px;
padding: 0;
border: 1px solid var(--glass-border);
border-radius: 9px;
background: var(--pill-bg);
color: var(--text-main);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.locate-btn:hover,
.filter-clear-btn:hover {
border-color: var(--glass-hover-border);
transform: translateY(-1px);
}
.locate-btn svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
}
.locate-btn span { display: none; }
.locate-btn.is-loading svg { animation: locate-pulse 1s ease-in-out infinite; }
.locate-btn.is-active {
border-color: rgba(0, 113, 67, 0.28);
background: rgba(0, 113, 67, 0.1);
color: var(--accent-primary);
}
.locate-btn.is-error {
border-color: rgba(220, 38, 38, 0.25);
color: #dc2626;
}
@keyframes locate-pulse {
50% { opacity: 0.45; transform: scale(0.86); }
}
.explore-search-shell {
position: relative;
min-width: 0;
}
.entity-search-field {
display: flex;
align-items: center;
width: min(320px, calc(100vw - 112px));
min-width: 180px;
height: 32px;
gap: 6px;
padding: 0 8px;
border: 1px solid var(--glass-border);
border-radius: 9px;
background: var(--pill-bg);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.entity-search-field:focus-within {
border-color: rgba(2, 132, 199, 0.45);
box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.08);
}
.entity-search-field > svg {
width: 14px;
height: 14px;
flex: 0 0 14px;
fill: none;
stroke: var(--text-muted);
stroke-width: 1.8;
}
.entity-search-field input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--text-main);
font: 11px/1.2 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
user-select: text;
}
.entity-search-field input::placeholder { color: var(--text-sub); }
.search-clear-btn {
width: 20px;
height: 20px;
flex: 0 0 20px;
border: 0;
border-radius: 50%;
background: var(--segmented-bg);
color: var(--text-muted);
cursor: pointer;
}
.entity-search-field input::-webkit-search-cancel-button { display: none; }
.filter-clear-btn[hidden],
.search-clear-btn[hidden],
.location-feedback[hidden] { display: none; }
.explore-suggestions {
position: absolute;
top: calc(100% + 7px);
left: 0;
width: min(390px, calc(100vw - 32px));
max-height: min(360px, calc(100dvh - 180px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--glass-border);
border-radius: 13px;
background: color-mix(in srgb, var(--glass-bg) 96%, transparent);
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(26px);
-webkit-backdrop-filter: blur(26px);
}
.explore-suggestions[hidden],
.filter-chip-rail[hidden] { display: none; }
.suggestion-section-label {
display: block;
padding: 5px 8px 4px;
color: var(--text-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.04em;
}
.explore-suggestion {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
width: 100%;
min-height: 42px;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: 9px;
background: transparent;
color: var(--text-main);
text-align: left;
cursor: pointer;
}
.explore-suggestion:hover,
.explore-suggestion.is-active { background: var(--pill-bg); }
.suggestion-symbol {
display: grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: 7px;
background: rgba(2, 132, 199, 0.1);
color: var(--accent-blue);
font-size: 12px;
font-weight: 700;
}
.suggestion-symbol.is-location { background: rgba(0, 113, 67, 0.1); color: var(--accent-primary); }
.suggestion-copy { min-width: 0; }
.suggestion-copy strong { display: block; overflow: hidden; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-copy small { display: block; overflow: hidden; margin-top: 2px; color: var(--text-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
.suggestion-kind { color: var(--text-sub); font-size: 9px; white-space: nowrap; }
.suggestion-empty {
display: block;
padding: 16px 10px;
color: var(--text-muted);
font-size: 10px;
text-align: center;
}
.filter-chip-rail {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 300px;
overflow-x: auto;
scrollbar-width: none;
}
.filter-chip-rail::-webkit-scrollbar { display: none; }
.filter-chip {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
padding: 0 7px;
border: 1px solid rgba(0, 113, 67, 0.15);
border-radius: 999px;
background: rgba(0, 113, 67, 0.08);
color: var(--accent-primary);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.filter-chip button {
display: grid;
width: 15px;
height: 15px;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: currentColor;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.filter-chip button:hover { background: rgba(0, 113, 67, 0.13); }
.filter-select-wrap {
position: relative;
display: block;
}
.filter-select-wrap::after {
content: '';
position: absolute;
top: 50%;
right: 9px;
width: 5px;
height: 5px;
border-right: 1.5px solid var(--text-muted);
border-bottom: 1.5px solid var(--text-muted);
pointer-events: none;
transform: translateY(-70%) rotate(45deg);
}
.filter-select-wrap select {
width: 104px;
height: 32px;
padding: 0 25px 0 9px;
border: 1px solid var(--glass-border);
border-radius: 9px;
outline: 0;
appearance: none;
background: var(--pill-bg);
color: var(--text-main);
font-size: 10px;
cursor: pointer;
}
.filter-select-wrap select:focus {
border-color: rgba(2, 132, 199, 0.45);
}
.filter-select-wrap select:disabled {
opacity: 0.46;
cursor: not-allowed;
}
.filter-result-meta {
flex: 0 0 auto;
padding: 0 3px;
color: var(--text-muted);
font: 9px/1.2 'JetBrains Mono', monospace;
white-space: nowrap;
}
.location-feedback {
position: absolute;
top: calc(100% + 6px);
left: 0;
max-width: min(360px, 90vw);
padding: 7px 10px;
border: 1px solid rgba(220, 38, 38, 0.18);
border-radius: 9px;
background: var(--glass-bg);
color: #b91c1c;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
font-size: 10px;
line-height: 1.4;
backdrop-filter: blur(18px);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.status-glass-pill {
display: flex;
align-items: center;
@@ -392,6 +721,144 @@ body {
height: 100%;
}
.map-detail-card {
position: absolute;
left: 14px;
bottom: 18px;
z-index: 15;
width: min(292px, calc(100% - 148px));
max-height: min(236px, calc(100% - 120px));
overflow: auto;
padding: 11px 12px;
border: 1px solid var(--glass-border);
border-radius: 15px;
background: var(--glass-bg);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.2);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
animation: detail-card-enter 0.2s ease-out;
}
.map-detail-card[hidden] { display: none; }
@keyframes detail-card-enter {
from { opacity: 0; transform: translateY(7px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.detail-card-accent {
position: absolute;
inset: 0 auto 0 0;
width: 3px;
border-radius: 15px 0 0 15px;
background: var(--accent-blue);
}
.detail-card-accent.is-station { background: var(--accent-primary); }
.detail-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--glass-border);
}
.detail-title-wrap { min-width: 0; }
.detail-type-pill {
display: inline-flex;
margin-bottom: 4px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: var(--accent-blue);
font-size: 9px;
font-weight: 700;
}
.detail-type-pill.is-station {
background: rgba(0, 113, 67, 0.1);
color: var(--accent-primary);
}
.detail-title-wrap h2 {
overflow: hidden;
color: var(--text-main);
font-size: 13px;
font-weight: 700;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-title-wrap p {
overflow: hidden;
margin-top: 2px;
color: var(--text-muted);
font-size: 9px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-close-btn {
display: grid;
width: 22px;
height: 22px;
flex: 0 0 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--pill-bg);
color: var(--text-muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px 10px;
padding-top: 8px;
}
.detail-field { min-width: 0; }
.detail-field.is-wide { grid-column: 1 / -1; }
.detail-field dt {
margin-bottom: 3px;
color: var(--text-muted);
font-size: 9px;
}
.detail-field dd {
overflow-wrap: anywhere;
color: var(--text-main);
font: 600 10px/1.35 'JetBrains Mono', -apple-system, sans-serif;
}
.detail-field.is-extra { display: none; }
.map-detail-card.is-expanded .detail-field.is-extra { display: block; }
.detail-expand-btn {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--accent-blue);
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.detail-expand-btn[hidden] { display: none; }
.map-action-controls {
position: absolute;
bottom: 24px;
@@ -627,6 +1094,11 @@ body {
border-color: var(--glass-hover-border);
}
.rank-glass-row.is-selected {
border-color: rgba(2, 132, 199, 0.42);
background: rgba(2, 132, 199, 0.08);
}
.r-badge {
width: 18px;
height: 18px;
@@ -728,6 +1200,35 @@ body {
.province-info-card.is-vehicle-point.is-online .p-dot { background: #00a86b; }
.province-info-card.is-vehicle-point.is-offline .p-dot { background: #8e8e93; }
.province-info-card.is-selected {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 3px rgba(2, 132, 199, 0.12), 0 9px 24px rgba(2, 132, 199, 0.2);
}
.user-location-marker {
position: relative;
width: 18px;
height: 18px;
border: 3px solid #fff;
border-radius: 50%;
background: #1677ff;
box-shadow: 0 3px 10px rgba(22, 119, 255, 0.38);
}
.user-location-marker::before {
content: '';
position: absolute;
inset: -8px;
border: 1px solid rgba(22, 119, 255, 0.35);
border-radius: 50%;
animation: user-location-wave 1.8s ease-out infinite;
}
@keyframes user-location-wave {
from { opacity: 0.9; transform: scale(0.55); }
to { opacity: 0; transform: scale(1.35); }
}
.province-info-badge-wrap:hover .province-info-card {
transform: scale(1.15);
border-color: var(--accent-cyan) !important;
@@ -769,6 +1270,10 @@ body {
/* --------------------------------------------------------------------------
MOBILE / NARROW VIEWPORT
-------------------------------------------------------------------------- */
@media (min-width: 769px) and (max-width: 1180px) {
.filter-chip-rail { max-width: 190px; }
}
@media (max-width: 768px) {
body {
height: auto;
@@ -891,6 +1396,49 @@ body {
right: 8px;
}
.map-explore-toolbar {
top: 44px;
left: 8px;
right: 8px;
max-width: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 6px;
}
.locate-btn {
flex: 0 0 32px;
}
.explore-search-shell { flex: 1 1 170px; }
.entity-search-field {
width: 100%;
min-width: 0;
}
.filter-chip-rail {
order: 3;
flex: 1 1 calc(100% - 78px);
max-width: none;
overflow-x: auto;
overscroll-behavior-x: contain;
}
.filter-result-meta {
order: 4;
flex: 0 0 auto;
max-width: 76px;
overflow: hidden;
text-overflow: ellipsis;
}
.explore-suggestions {
width: min(420px, calc(100vw - 32px));
max-height: 300px;
}
.status-glass-pill {
min-width: 0;
max-width: 100%;
@@ -914,6 +1462,17 @@ body {
gap: 4px;
}
.map-detail-card {
left: 8px;
right: 8px;
bottom: 54px;
width: auto;
max-height: min(220px, calc(100% - 140px));
padding: 10px 12px;
}
.detail-grid { gap: 8px 12px; }
.glass-btn {
padding: 5px 7px;
font-size: 9px;
+20 -1
View File
@@ -12,6 +12,13 @@ dashboard = {
{ vin: 'VIN-X-1', plateNumber: '观光车001', online: false, dailyMileageKm: 0, locationAvailable: false }
]
};
filterState.query = '粤a';
assert.deepEqual(filteredVehicles().map(vehicle => vehicle.vin), ['VIN-GD-1']);
filterState.query = '';
vehicleFilterGroups.provinces = [{ adcode: '440000', vehicles: [dashboard.vehicles[0]] }];
filterState.province = '440000';
assert.deepEqual(filteredVehicles().map(vehicle => vehicle.vin), ['VIN-GD-1']);
filterState.province = '';
const areaNode = {
groupByPosition(points) {
return [
@@ -75,12 +82,24 @@ assert.equal(points.find(node => node.nameZh === '粤A00001').online, 1);
dashboard = {
stations: [
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
{ id: 'GD-1', name: '广州合作站', province: '广东省', city: '广州市', address: '广东省广州市黄埔区开源大道1号', longitude: 113.2, latitude: 23.1, cooperative: true, totalHydrogenKg: 20 },
{ id: 'GD-2', name: '佛山外部站', province: '广东省', city: '佛山市', longitude: 113.1, latitude: 23.0, cooperative: false, totalHydrogenKg: 10 },
{ id: 'ZJ-1', name: '嘉兴合作站', province: '浙江省', city: '嘉兴市', longitude: 120.7, latitude: 30.7, cooperative: true, totalHydrogenKg: 30 }
]
};
currentMode = 'station';
assert.equal(stationDistrictName(dashboard.stations[0]), '黄埔区');
assert.equal(fuzzySearchScore('广州', 'gz'), 3);
assert.equal(fuzzySearchScore('广州市', '广州'), 1);
assert.ok(stationLocationOptions().some(option => option.level === 'district' && option.district === '黄埔区'));
filterState.query = '合作';
assert.deepEqual(filteredStations().map(station => station.id).sort(), ['GD-1', 'ZJ-1']);
filterState.query = '';
filterState.province = '广东省'; filterState.city = '广州市'; filterState.district = '黄埔区';
assert.deepEqual(filteredStations().map(station => station.id), ['GD-1']);
filterState.province = ''; filterState.city = ''; filterState.district = '';
assert.ok(Math.abs(distanceKm([113, 23], [114, 23]) - 102.4) < 1);
assert.equal(locationCoordinates({ coords: { longitude: NaN, latitude: 23 } }), null);
map = {
getZoom: () => 12,
getBounds: () => ({