diff --git a/vehicle-map/app.js b/vehicle-map/app.js index 54687077..4e99dfbb 100644 --- a/vehicle-map/app.js +++ b/vehicle-map/app.js @@ -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 = `${escapeHTML(dict.searchEmpty)}`; + return; + } + let html = ''; + if (locations.length) { + html += `${escapeHTML(dict.searchLocations)}`; + html += locations.map((item, index) => ``).join(''); + } + if (entities.length) { + html += `${escapeHTML(dict.searchEntities)}`; + html += entities.map((item, entityIndex) => { + const index = locations.length + entityIndex; + const isStation = item.mode === 'station'; + const name = isStation ? item.entity.name || item.entity.shortName : item.entity.plateNumber || item.entity.vin; + const subline = isStation ? stationAdministrativePath(item.entity) : item.entity.vin; + return ``; + }).join(''); + } + box.innerHTML = html; +} + +function openExploreSuggestions() { + const box = document.getElementById('exploreSuggestions'); + if (!box) return; + box.hidden = false; + if (currentMode === 'vehicle' && !vehicleExploreCatalog.ready) { + void ensureVehicleExploreCatalog().then(() => renderExploreSuggestions()); + } + renderExploreSuggestions(); +} + +function closeExploreSuggestions() { + const box = document.getElementById('exploreSuggestions'); + if (box) box.hidden = true; + activeExploreSuggestionIndex = -1; +} + +async function selectExploreLocation(option) { + filterState.query = ''; + if (currentMode === 'station') { + filterState.province = option.province || ''; + filterState.city = option.city || ''; + filterState.district = option.district || ''; + } else { + vehicleFilterGroups = { + provinces: vehicleExploreCatalog.provinces.length ? vehicleExploreCatalog.provinces : vehicleFilterGroups.provinces, + cities: vehicleExploreCatalog.cities.length ? vehicleExploreCatalog.cities : vehicleFilterGroups.cities, + districts: vehicleExploreCatalog.districts.length ? vehicleExploreCatalog.districts : vehicleFilterGroups.districts + }; + filterState.province = option.provinceGroup?.adcode || ''; + filterState.city = option.cityGroup?.adcode || ''; + filterState.district = option.districtGroup?.adcode || ''; + } + closeExploreSuggestions(); + updateFilterToolbar(); + focusCurrentRegion(option.level); + applyActiveFilters(); + void syncFilterControls(dashboard); +} + +function selectExploreEntity(item) { + const entity = item.entity; + if (!entity) return; + closeExploreSuggestions(); + const coordinates = item.mode === 'vehicle' + ? entity.locationAvailable ? wgs84ToGcj02(Number(entity.longitude), Number(entity.latitude)) : null + : [Number(entity.longitude), Number(entity.latitude)]; + if (coordinates?.every(Number.isFinite)) map?.setZoomAndCenter(14, coordinates); + showEntityDetails(item.mode, entity); +} + +function selectExploreSuggestion(index) { + const item = exploreSuggestionItems[index]; + if (!item) return; + if (item.type === 'location') void selectExploreLocation(item); + else selectExploreEntity(item); +} + +function handleExploreSearchKeydown(event) { + if (event.key === 'Escape') { closeExploreSuggestions(); event.currentTarget.blur(); return; } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return; + const box = document.getElementById('exploreSuggestions'); + if (box?.hidden) openExploreSuggestions(); + if (event.key === 'Enter' && activeExploreSuggestionIndex >= 0) { + event.preventDefault(); selectExploreSuggestion(activeExploreSuggestionIndex); return; + } + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const direction = event.key === 'ArrowDown' ? 1 : -1; + activeExploreSuggestionIndex = (activeExploreSuggestionIndex + direction + exploreSuggestionItems.length) % Math.max(exploreSuggestionItems.length, 1); + renderExploreSuggestions(); + } +} + +function selectedLocationLabel(level) { + if (currentMode === 'station') { + const raw = filterState[level]; + return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw); + } + const group = level === 'province' ? vehicleFilterGroups.provinces.find(item => item.adcode === filterState.province) + : level === 'city' ? vehicleFilterGroups.cities.find(item => item.adcode === filterState.city) + : vehicleFilterGroups.districts.find(item => item.adcode === filterState.district); + return group ? groupLocationName(group, level) : ''; +} + +function renderFilterChips() { + const rail = document.getElementById('filterChipRail'); + if (!rail) return; + const levels = ['province', 'city', 'district'].filter(level => filterState[level]); + rail.hidden = !levels.length; + rail.innerHTML = levels.map(level => `${escapeHTML(selectedLocationLabel(level))}`).join(''); +} + +function clearLocationChip(level) { + filterState[level] = ''; + if (level === 'province') { filterState.city = ''; filterState.district = ''; } + if (level === 'city') filterState.district = ''; + void syncFilterControls(dashboard); + applyActiveFilters(); +} + +function updateFilterToolbar() { + const dict = i18n[currentLang]; + const search = document.getElementById('entitySearch'); + const searchClear = document.getElementById('searchClearBtn'); + const meta = document.getElementById('filterResultMeta'); + if (search) { + search.placeholder = dict.searchUniversal; + search.setAttribute('aria-label', dict.searchUniversal); + if (search.value !== filterState.query) search.value = filterState.query; + } + if (searchClear) searchClear.hidden = !normalizeSearch(filterState.query); + const all = currentMode === 'vehicle' ? dashboard?.vehicles?.length || 0 : dashboard?.stations?.length || 0; + const filtered = currentMode === 'vehicle' ? filteredVehicles().length : filteredStations().length; + if (meta) meta.textContent = isFilterActive() ? `${dict.filterResult} ${formatNumber(filtered)} / ${formatNumber(all)}` : `${dict.viewportAll} ${formatNumber(all)}`; + renderFilterChips(); + renderExploreSuggestions(); +} + +function focusCoordinates(items, zoom) { + const located = items.filter(item => Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude))); + if (!map || !located.length) return; + const center = located.reduce((sum, item) => [sum[0] + Number(item.longitude), sum[1] + Number(item.latitude)], [0, 0]); + map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]); +} + +function 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 `