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 `
${escapeHTML(label)}
${escapeHTML(value)}
`; +} + +function vehicleStatusLabel(vehicle) { + const status = vehicleActiveToday(vehicle) + ? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped + : i18n[currentLang].statusOffline; + return status; +} + +function 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 = `
${escapeHTML(node.name)}${escapeHTML(label)}
${(isVehicleNode || isStationPoint) && subline ? `
${escapeHTML(subline)}
` : ''}
`; + markerContent.innerHTML = `
${escapeHTML(node.name)}${escapeHTML(label)}
${(isVehicleNode || isStationPoint) && subline ? `
${escapeHTML(subline)}
` : ''}
`; const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name }); const infoWindow = new AMap.InfoWindow({ isCustom: true, @@ -637,6 +1288,7 @@ function renderAMapMarkers() { if (node.kind === 'vehicleDistrict') marker.on('click', () => map.setZoomAndCenter(12.2, node.lnglat)); if (node.kind === 'stationProvince' || node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat)); if (node.kind === 'stationCity') marker.on('click', () => map.setZoomAndCenter(11.2, node.lnglat)); + if (node.kind === 'vehiclePoint' || node.kind === 'station') marker.on('click', () => showNodeDetails(node)); marker.setMap(map); markerList.push(marker); } } @@ -647,26 +1299,48 @@ function renderRankingList(type) { box.innerHTML = ''; let rows; if (currentMode === 'vehicle') { - rows = buildVehicleRegionNodes().nodes - .sort((a, b) => type === 'secondary' ? b.dist - a.dist : b.count - a.count) - .slice(0, 12) - .map(item => ({ - name: item.name, - value: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'), - location: item.lnglat, - zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14 - })); + if (normalizeSearch(filterState.query)) { + rows = filteredVehicles() + .sort((left, right) => Number(vehicleActiveToday(right)) - Number(vehicleActiveToday(left)) || String(left.plateNumber || left.vin).localeCompare(String(right.plateNumber || right.vin), 'zh-CN')) + .map(vehicle => { + const hasLocation = vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude)); + const location = hasLocation ? wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)) : null; + return { + name: vehicle.plateNumber || vehicle.vin, + value: userLocation && location ? `${formatNumber(distanceKm(userLocation, location), 1)} km` : vehicleStatusLabel(vehicle), + location, zoom: 14, entity: vehicle, mode: 'vehicle' + }; + }); + } else { + rows = buildVehicleRegionNodes().nodes + .sort((a, b) => userLocation && a.kind === 'vehiclePoint' + ? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat) + : type === 'secondary' ? b.dist - a.dist : b.count - a.count) + .slice(0, 100) + .map(item => ({ + name: item.name, + value: userLocation && item.kind === 'vehiclePoint' ? `${formatNumber(distanceKm(userLocation, item.lnglat), 1)} km` + : type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'), + location: item.lnglat, + zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14, + node: item, entity: item.kind === 'vehiclePoint' ? item.vehicle || item.vehicles?.[0] : null, mode: 'vehicle' + })); + } } else { const { level, allNodes, visibleNodes } = stationViewportSummary(); const byHydrogen = type === 'secondary'; rows = [...visibleNodes] .filter(node => !byHydrogen || node.hasHydrogen) - .sort((a, b) => byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN')) + .sort((a, b) => userLocation && level === 'station' + ? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat) + : byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN')) .map(node => ({ name: node.name, - value: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'), + value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km` + : byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'), location: node.lnglat, - zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14 + zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14, + node, entity: node.kind === 'station' ? node.station : null, mode: 'station' })); updateStationViewportMeta(visibleNodes.length, allNodes.length); if (!rows.length) { @@ -675,9 +1349,13 @@ function renderRankingList(type) { } } rows.forEach((item, index) => { - const row = document.createElement('div'); row.className = 'rank-glass-row'; + const row = document.createElement('div'); + row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`; row.innerHTML = `${index + 1}${escapeHTML(item.name)}${escapeHTML(item.value)}`; - 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 => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); } function initClock() { diff --git a/vehicle-map/index.html b/vehicle-map/index.html index e94b0cfd..27c20f52 100644 --- a/vehicle-map/index.html +++ b/vehicle-map/index.html @@ -8,7 +8,7 @@ - + - + diff --git a/vehicle-map/styles.css b/vehicle-map/styles.css index 09adecd8..1c156372 100644 --- a/vehicle-map/styles.css +++ b/vehicle-map/styles.css @@ -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; diff --git a/vehicle-map/tests/test_app.mjs b/vehicle-map/tests/test_app.mjs index 995b5ede..7c587f31 100644 --- a/vehicle-map/tests/test_app.mjs +++ b/vehicle-map/tests/test_app.mjs @@ -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: () => ({