feat(map): streamline search and detail panels
This commit is contained in:
+796
-29
@@ -17,7 +17,16 @@ const i18n = {
|
||||
stationCooperative: '合作站', stationExternal: '外部站', stationTotal: '全部站点', stationPartnerMarker: '合作',
|
||||
rankByFleet: '按数量', rankByDist: '按里程', rankByStations: '按站点数', rankByHydrogen: '按加氢量', rankNoHydrogenData: '当前视野暂无加氢数据',
|
||||
stationViewportEmpty: '当前视野暂无加氢站 · 可拖动地图或复位视角', viewportNow: '当前视野', viewportAll: '全部',
|
||||
onlineText: '在线', unitTail: '台'
|
||||
onlineText: '在线', unitTail: '台', btnLocate: '定位', locating: '定位中', located: '已定位', locateFailed: '定位失败',
|
||||
btnClearFilters: '清除', filterAllProvince: '全部省份', filterAllCity: '全部城市', filterAllDistrict: '全部区县',
|
||||
filterResult: '筛选', searchVehicle: '搜索车牌 / VIN', searchStation: '搜索站点名称 / 地址', nearbyVehicles: '附近车辆', nearbyStations: '附近加氢站',
|
||||
detailVehicle: '车辆', detailStation: '加氢站', detailPlate: '车牌', detailVin: 'VIN', detailStatus: '车辆状态', detailActive: '今日上线',
|
||||
detailSpeed: '当前速度', detailDailyMileage: '今日里程', detailTotalMileage: '累计里程', detailLocation: '实时位置', detailCoordinate: '坐标',
|
||||
detailStationType: '站点类型', detailAdmin: '行政区域', detailAddress: '详细地址', detailMonthlyHydrogen: '本月加氢量', detailTotalHydrogen: '累计加氢量',
|
||||
valueYes: '是', valueNo: '否', valueUnavailable: '暂无数据', locatePermissionHint: '无法获取位置,请在浏览器中允许位置权限后重试',
|
||||
locateUnavailableHint: '暂时无法获取设备位置,请稍后重试', locateTimeoutHint: '定位超时,请到开阔区域后重试',
|
||||
searchUniversal: '搜索位置、站点、车牌或 VIN', searchLocations: '位置', searchEntities: '业务对象', searchEmpty: '没有匹配的地点或对象',
|
||||
suggestionLocation: '地点', suggestionVehicle: '车辆', suggestionStation: '加氢站', detailMore: '展开全部信息', detailLess: '收起详细信息'
|
||||
},
|
||||
en: {
|
||||
vehicleModeTitle: 'Vehicle Network', stationModeTitle: 'H₂ Station Network',
|
||||
@@ -35,7 +44,16 @@ const i18n = {
|
||||
stationCooperative: 'Partners', stationExternal: 'External', stationTotal: 'All Stations', stationPartnerMarker: 'Partner',
|
||||
rankByFleet: 'By Count', rankByDist: 'By Mileage', rankByStations: 'By Stations', rankByHydrogen: 'By H₂ Volume', rankNoHydrogenData: 'No hydrogen data in this view',
|
||||
stationViewportEmpty: 'No H₂ stations in this view · Pan the map or reset view', viewportNow: 'In view', viewportAll: 'All',
|
||||
onlineText: 'Online', unitTail: 'units'
|
||||
onlineText: 'Online', unitTail: 'units', btnLocate: 'Locate', locating: 'Locating', located: 'Located', locateFailed: 'Location failed',
|
||||
btnClearFilters: 'Clear', filterAllProvince: 'All provinces', filterAllCity: 'All cities', filterAllDistrict: 'All districts',
|
||||
filterResult: 'Filtered', searchVehicle: 'Search plate / VIN', searchStation: 'Search station / address', nearbyVehicles: 'Nearby vehicles', nearbyStations: 'Nearby H₂ stations',
|
||||
detailVehicle: 'Vehicle', detailStation: 'H₂ station', detailPlate: 'Plate', detailVin: 'VIN', detailStatus: 'Status', detailActive: 'Active today',
|
||||
detailSpeed: 'Speed', detailDailyMileage: 'Today mileage', detailTotalMileage: 'Total mileage', detailLocation: 'Live location', detailCoordinate: 'Coordinates',
|
||||
detailStationType: 'Station type', detailAdmin: 'Region', detailAddress: 'Address', detailMonthlyHydrogen: 'Monthly hydrogen', detailTotalHydrogen: 'Total hydrogen',
|
||||
valueYes: 'Yes', valueNo: 'No', valueUnavailable: 'Unavailable', locatePermissionHint: 'Location access is blocked. Allow it in your browser and try again.',
|
||||
locateUnavailableHint: 'Your location is temporarily unavailable. Please try again.', locateTimeoutHint: 'Location timed out. Move to an open area and try again.',
|
||||
searchUniversal: 'Search location, station, plate, or VIN', searchLocations: 'Locations', searchEntities: 'Results', searchEmpty: 'No matching location or entity',
|
||||
suggestionLocation: 'Location', suggestionVehicle: 'Vehicle', suggestionStation: 'H₂ station', detailMore: 'Show all details', detailLess: 'Collapse details'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,6 +98,17 @@ let districtExplorerPromise = null;
|
||||
const areaNodePromises = new Map();
|
||||
let regionAggregationVersion = 0;
|
||||
let vehicleRegionSummary = { level: 'province', nodes: [], unassigned: 0, loading: true };
|
||||
let filterSyncVersion = 0;
|
||||
let filterDebounceTimer = null;
|
||||
let selectedEntity = null;
|
||||
let userLocation = null;
|
||||
let userLocationMarker = null;
|
||||
let vehicleFilterGroups = { provinces: [], cities: [], districts: [] };
|
||||
let vehicleExploreCatalog = { ready: false, loading: null, options: [], provinces: [], cities: [], districts: [] };
|
||||
let exploreSuggestionItems = [];
|
||||
let activeExploreSuggestionIndex = -1;
|
||||
let detailExpanded = false;
|
||||
const filterState = { province: '', city: '', district: '', query: '' };
|
||||
|
||||
const REGION_ZOOM = { city: 7, district: 9.5, vehicle: 12 };
|
||||
const MUNICIPALITIES = new Set(['北京', '天津', '上海', '重庆']);
|
||||
@@ -91,6 +120,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshTimer = window.setInterval(loadDashboard, 15000);
|
||||
});
|
||||
|
||||
document.addEventListener('pointerdown', event => {
|
||||
if (!event.target?.closest?.('.map-explore-toolbar')) closeExploreSuggestions();
|
||||
});
|
||||
|
||||
async function loadDashboard() {
|
||||
setDataState('loading');
|
||||
try {
|
||||
@@ -98,6 +131,7 @@ async function loadDashboard() {
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||||
dashboard = payload;
|
||||
void syncFilterControls(payload);
|
||||
updateDashboardUI();
|
||||
refreshVehicleRegionNodes(payload);
|
||||
setDataState('ready');
|
||||
@@ -114,6 +148,498 @@ function setDataState(state) {
|
||||
if (state === 'error') el.textContent = currentLang === 'zh' ? '数据同步失败 · 将自动重试' : 'Data sync failed · Retrying';
|
||||
}
|
||||
|
||||
function normalizeSearch(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function stationDistrictName(station) {
|
||||
if (station?.district) return String(station.district).trim();
|
||||
let address = String(station?.address || '').trim();
|
||||
for (const prefix of [station?.province, station?.city]) {
|
||||
const normalized = String(prefix || '').trim();
|
||||
if (normalized) address = address.split(normalized).join('');
|
||||
}
|
||||
return address.match(/^(.{2,10}?(?:区|县|旗|市))/)?.[1] || '';
|
||||
}
|
||||
|
||||
function vehicleMatchesQuery(vehicle, query = filterState.query) {
|
||||
const needle = normalizeSearch(query);
|
||||
if (!needle) return true;
|
||||
return [vehicle?.plateNumber, vehicle?.vin].some(value => normalizeSearch(value).includes(needle));
|
||||
}
|
||||
|
||||
function stationMatchesQuery(station, query = filterState.query) {
|
||||
const needle = normalizeSearch(query);
|
||||
if (!needle) return true;
|
||||
return [station?.name, station?.shortName, station?.address].some(value => normalizeSearch(value).includes(needle));
|
||||
}
|
||||
|
||||
function selectedVehicleFilterGroup() {
|
||||
if (filterState.district) return vehicleFilterGroups.districts.find(group => group.adcode === filterState.district) || null;
|
||||
if (filterState.city) return vehicleFilterGroups.cities.find(group => group.adcode === filterState.city) || null;
|
||||
if (filterState.province) return vehicleFilterGroups.provinces.find(group => group.adcode === filterState.province) || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function filteredVehicles(vehicles = dashboard?.vehicles || []) {
|
||||
const group = selectedVehicleFilterGroup();
|
||||
const allowedVins = group ? new Set(group.vehicles.map(vehicle => vehicle.vin)) : null;
|
||||
return vehicles.filter(vehicle => (!allowedVins || allowedVins.has(vehicle.vin)) && vehicleMatchesQuery(vehicle));
|
||||
}
|
||||
|
||||
function filteredStations(stations = dashboard?.stations || []) {
|
||||
return stations.filter(station => {
|
||||
if (filterState.province && station.province !== filterState.province) return false;
|
||||
if (filterState.city && station.city !== filterState.city) return false;
|
||||
if (filterState.district && stationDistrictName(station) !== filterState.district) return false;
|
||||
return stationMatchesQuery(station);
|
||||
});
|
||||
}
|
||||
|
||||
function isFilterActive() {
|
||||
return Boolean(filterState.province || filterState.city || filterState.district || normalizeSearch(filterState.query));
|
||||
}
|
||||
|
||||
function uniqueSortedOptions(values) {
|
||||
return [...new Set(values.filter(Boolean).map(value => String(value).trim()).filter(Boolean))]
|
||||
.sort((left, right) => left.localeCompare(right, 'zh-CN'));
|
||||
}
|
||||
|
||||
function filterGroupLabel(group, level) {
|
||||
if (!group) return '';
|
||||
return currentLang === 'en' ? administrativeEnglishName(group.rawName || group.nameZh, level) : group.nameZh;
|
||||
}
|
||||
|
||||
function populateSelect(selectId, options, value, allLabel, disabled = false) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
const all = document.createElement('option'); all.value = ''; all.textContent = allLabel; select.appendChild(all);
|
||||
for (const option of options) {
|
||||
const element = document.createElement('option');
|
||||
element.value = option.value; element.textContent = option.label; select.appendChild(element);
|
||||
}
|
||||
select.value = options.some(option => option.value === value) ? value : '';
|
||||
select.disabled = disabled;
|
||||
}
|
||||
|
||||
function syncStationFilterControls() {
|
||||
const dict = i18n[currentLang];
|
||||
const stations = dashboard?.stations || [];
|
||||
const provinces = uniqueSortedOptions(stations.map(station => station.province));
|
||||
if (filterState.province && !provinces.includes(filterState.province)) {
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = '';
|
||||
}
|
||||
const provinceStations = filterState.province ? stations.filter(station => station.province === filterState.province) : [];
|
||||
const cities = uniqueSortedOptions(provinceStations.map(station => station.city));
|
||||
if (filterState.city && !cities.includes(filterState.city)) { filterState.city = ''; filterState.district = ''; }
|
||||
const cityStations = filterState.city ? provinceStations.filter(station => station.city === filterState.city) : [];
|
||||
const districts = uniqueSortedOptions(cityStations.map(stationDistrictName));
|
||||
if (filterState.district && !districts.includes(filterState.district)) filterState.district = '';
|
||||
populateSelect('provinceFilter', provinces.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'province') : compactProvinceName(value) })), filterState.province, dict.filterAllProvince);
|
||||
populateSelect('cityFilter', cities.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'city') : compactRegionName(value) })), filterState.city, dict.filterAllCity, !filterState.province);
|
||||
populateSelect('districtFilter', districts.map(value => ({ value, label: currentLang === 'en' ? administrativeEnglishName(value, 'district') : compactRegionName(value) })), filterState.district, dict.filterAllDistrict, !filterState.city);
|
||||
}
|
||||
|
||||
async function buildVehicleFilterGroups(vehicles) {
|
||||
const nationalNode = await areaNodeFor(100000);
|
||||
const provinces = partitionVehiclesByArea(nationalNode, locatedVehicles(vehicles), 'province').groups;
|
||||
let cities = [];
|
||||
let districts = [];
|
||||
const province = provinces.find(group => group.adcode === filterState.province);
|
||||
if (province) cities = await expandRegionGroups([province], 'city');
|
||||
const city = cities.find(group => group.adcode === filterState.city);
|
||||
if (city) districts = await expandRegionGroups([city], 'district');
|
||||
return { provinces, cities, districts };
|
||||
}
|
||||
|
||||
function syncVehicleFilterControls() {
|
||||
const dict = i18n[currentLang];
|
||||
const levels = [
|
||||
['provinceFilter', vehicleFilterGroups.provinces, filterState.province, dict.filterAllProvince, 'province', false],
|
||||
['cityFilter', vehicleFilterGroups.cities, filterState.city, dict.filterAllCity, 'city', !filterState.province],
|
||||
['districtFilter', vehicleFilterGroups.districts, filterState.district, dict.filterAllDistrict, 'district', !filterState.city]
|
||||
];
|
||||
for (const [id, groups, value, allLabel, level, disabled] of levels) {
|
||||
const options = groups.map(group => ({ value: group.adcode, label: filterGroupLabel(group, level) }))
|
||||
.sort((left, right) => left.label.localeCompare(right.label, currentLang === 'zh' ? 'zh-CN' : 'en-US'));
|
||||
populateSelect(id, options, value, allLabel, disabled);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncFilterControls(sourceDashboard = dashboard) {
|
||||
if (!sourceDashboard) return;
|
||||
const version = ++filterSyncVersion;
|
||||
if (currentMode === 'station') {
|
||||
syncStationFilterControls();
|
||||
updateFilterToolbar();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const groups = await buildVehicleFilterGroups(sourceDashboard.vehicles || []);
|
||||
if (version !== filterSyncVersion || sourceDashboard !== dashboard || currentMode !== 'vehicle') return;
|
||||
vehicleFilterGroups = groups;
|
||||
if (filterState.province && !groups.provinces.some(group => group.adcode === filterState.province)) {
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = '';
|
||||
}
|
||||
if (filterState.city && !groups.cities.some(group => group.adcode === filterState.city)) {
|
||||
filterState.city = ''; filterState.district = '';
|
||||
}
|
||||
if (filterState.district && !groups.districts.some(group => group.adcode === filterState.district)) filterState.district = '';
|
||||
syncVehicleFilterControls();
|
||||
updateFilterToolbar();
|
||||
} catch (error) {
|
||||
console.warn('vehicle filter regions unavailable', error);
|
||||
if (version === filterSyncVersion) updateFilterToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
function pinyinSearchVariants(value) {
|
||||
const text = String(value || '');
|
||||
const converter = globalThis.pinyinPro?.pinyin;
|
||||
if (typeof converter !== 'function' || !text) return [];
|
||||
const spaced = String(converter(text, { toneType: 'none', separator: ' ' }) || '');
|
||||
const compact = spaced.replace(/\s+/g, '').toLocaleLowerCase();
|
||||
const initials = spaced.split(/\s+/).filter(Boolean).map(part => part[0]).join('').toLocaleLowerCase();
|
||||
return [compact, initials].filter(Boolean);
|
||||
}
|
||||
|
||||
function fuzzySearchScore(value, query) {
|
||||
const needle = normalizeSearch(query);
|
||||
if (!needle) return 4;
|
||||
const normalized = normalizeSearch(value);
|
||||
if (normalized === needle) return 0;
|
||||
if (normalized.startsWith(needle)) return 1;
|
||||
if (normalized.includes(needle)) return 2;
|
||||
const variants = pinyinSearchVariants(value);
|
||||
if (variants.some(item => item === needle || item.startsWith(needle))) return 3;
|
||||
if (variants.some(item => item.includes(needle))) return 4;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function compactLocationPath(parts) {
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function stationLocationOptions() {
|
||||
const unique = new Map();
|
||||
const add = option => {
|
||||
const key = [option.level, option.province, option.city, option.district].join('|');
|
||||
if (!unique.has(key)) unique.set(key, option);
|
||||
};
|
||||
for (const station of dashboard?.stations || []) {
|
||||
const province = station.province || '';
|
||||
const city = station.city || '';
|
||||
const district = stationDistrictName(station);
|
||||
if (province) add({ type: 'location', level: 'province', province, city: '', district: '', label: currentLang === 'en' ? administrativeEnglishName(province, 'province') : compactProvinceName(province), path: compactLocationPath([stationAdministrativePath({ province })]) });
|
||||
if (province && city) add({ type: 'location', level: 'city', province, city, district: '', label: currentLang === 'en' ? administrativeEnglishName(city, 'city') : compactRegionName(city), path: compactLocationPath([stationAdministrativePath({ province, city })]) });
|
||||
if (province && city && district) add({ type: 'location', level: 'district', province, city, district, label: currentLang === 'en' ? administrativeEnglishName(district, 'district') : compactRegionName(district), path: compactLocationPath([stationAdministrativePath({ province, city }), currentLang === 'en' ? administrativeEnglishName(district, 'district') : district]) });
|
||||
}
|
||||
return [...unique.values()];
|
||||
}
|
||||
|
||||
function groupLocationName(group, level) {
|
||||
return currentLang === 'en' ? administrativeEnglishName(group.rawName || group.nameZh, level) : group.nameZh;
|
||||
}
|
||||
|
||||
function uniqueGroups(groups) {
|
||||
const values = new Map();
|
||||
for (const group of groups) if (!values.has(group.adcode)) values.set(group.adcode, group);
|
||||
return [...values.values()];
|
||||
}
|
||||
|
||||
async function ensureVehicleExploreCatalog() {
|
||||
if (vehicleExploreCatalog.ready) return vehicleExploreCatalog;
|
||||
if (vehicleExploreCatalog.loading) return vehicleExploreCatalog.loading;
|
||||
vehicleExploreCatalog.loading = (async () => {
|
||||
const vehicles = dashboard?.vehicles || [];
|
||||
const nationalNode = await areaNodeFor(100000);
|
||||
const provinces = partitionVehiclesByArea(nationalNode, locatedVehicles(vehicles), 'province').groups;
|
||||
const cities = uniqueGroups((await Promise.all(provinces.map(async province => {
|
||||
const children = await expandRegionGroups([province], 'city');
|
||||
return children.map(city => ({ ...city, parentAdcode: province.adcode, province }));
|
||||
}))).flat());
|
||||
const districts = uniqueGroups((await Promise.all(cities.map(async city => {
|
||||
const children = await expandRegionGroups([city], 'district');
|
||||
return children.map(district => ({ ...district, parentAdcode: city.adcode, city, province: city.province }));
|
||||
}))).flat());
|
||||
const provinceByCode = new Map(provinces.map(group => [group.adcode, group]));
|
||||
const cityByCode = new Map(cities.map(group => [group.adcode, group]));
|
||||
const options = [];
|
||||
for (const province of provinces) {
|
||||
const label = groupLocationName(province, 'province');
|
||||
options.push({ type: 'location', level: 'province', label, path: label, provinceGroup: province });
|
||||
}
|
||||
for (const city of cities) {
|
||||
const province = provinceByCode.get(city.parentAdcode) || city.province;
|
||||
const label = groupLocationName(city, 'city');
|
||||
options.push({ type: 'location', level: 'city', label, path: compactLocationPath([province && groupLocationName(province, 'province'), label]), provinceGroup: province, cityGroup: city });
|
||||
}
|
||||
for (const district of districts) {
|
||||
const city = cityByCode.get(district.parentAdcode) || district.city;
|
||||
const province = city?.province || district.province || provinceByCode.get(city?.parentAdcode);
|
||||
const label = groupLocationName(district, 'district');
|
||||
options.push({ type: 'location', level: 'district', label, path: compactLocationPath([province && groupLocationName(province, 'province'), city && groupLocationName(city, 'city'), label]), provinceGroup: province, cityGroup: city, districtGroup: district });
|
||||
}
|
||||
vehicleExploreCatalog = { ready: true, loading: null, options, provinces, cities, districts };
|
||||
return vehicleExploreCatalog;
|
||||
})().catch(error => {
|
||||
vehicleExploreCatalog.loading = null;
|
||||
console.warn('vehicle location search unavailable', error);
|
||||
return vehicleExploreCatalog;
|
||||
});
|
||||
return vehicleExploreCatalog.loading;
|
||||
}
|
||||
|
||||
function currentLocationOptions() {
|
||||
if (currentMode === 'station') return stationLocationOptions();
|
||||
if (vehicleExploreCatalog.ready) return vehicleExploreCatalog.options;
|
||||
return vehicleFilterGroups.provinces.map(group => {
|
||||
const label = groupLocationName(group, 'province');
|
||||
return { type: 'location', level: 'province', label, path: label, provinceGroup: group };
|
||||
});
|
||||
}
|
||||
|
||||
function locationSuggestionItems(query) {
|
||||
const needle = normalizeSearch(query);
|
||||
return currentLocationOptions()
|
||||
.map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, needle), fuzzySearchScore(option.path, needle)) }))
|
||||
.filter(option => Number.isFinite(option.score))
|
||||
.sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.slice(0, needle ? 8 : 6);
|
||||
}
|
||||
|
||||
function entitySuggestionItems(query) {
|
||||
const needle = normalizeSearch(query);
|
||||
if (!needle) return [];
|
||||
const entities = currentMode === 'vehicle' ? filteredVehicles() : filteredStations();
|
||||
return entities.slice(0, 5).map(entity => ({ type: 'entity', mode: currentMode, entity }));
|
||||
}
|
||||
|
||||
function renderExploreSuggestions() {
|
||||
const box = document.getElementById('exploreSuggestions');
|
||||
if (!box || box.hidden) return;
|
||||
const dict = i18n[currentLang];
|
||||
const query = filterState.query;
|
||||
const locations = locationSuggestionItems(query);
|
||||
const entities = entitySuggestionItems(query);
|
||||
exploreSuggestionItems = [...locations, ...entities];
|
||||
activeExploreSuggestionIndex = Math.min(activeExploreSuggestionIndex, exploreSuggestionItems.length - 1);
|
||||
if (!exploreSuggestionItems.length) {
|
||||
box.innerHTML = `<span class="suggestion-empty">${escapeHTML(dict.searchEmpty)}</span>`;
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
if (locations.length) {
|
||||
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchLocations)}</span>`;
|
||||
html += locations.map((item, index) => `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol is-location">⌖</span><span class="suggestion-copy"><strong>${escapeHTML(item.label)}</strong><small>${escapeHTML(item.path)}</small></span><span class="suggestion-kind">${escapeHTML(dict.suggestionLocation)}</span></button>`).join('');
|
||||
}
|
||||
if (entities.length) {
|
||||
html += `<span class="suggestion-section-label">${escapeHTML(dict.searchEntities)}</span>`;
|
||||
html += entities.map((item, entityIndex) => {
|
||||
const index = locations.length + entityIndex;
|
||||
const isStation = item.mode === 'station';
|
||||
const name = isStation ? item.entity.name || item.entity.shortName : item.entity.plateNumber || item.entity.vin;
|
||||
const subline = isStation ? stationAdministrativePath(item.entity) : item.entity.vin;
|
||||
return `<button type="button" class="explore-suggestion${index === activeExploreSuggestionIndex ? ' is-active' : ''}" role="option" onclick="selectExploreSuggestion(${index})"><span class="suggestion-symbol">${isStation ? 'H₂' : '车'}</span><span class="suggestion-copy"><strong>${escapeHTML(name)}</strong><small>${escapeHTML(subline || '')}</small></span><span class="suggestion-kind">${escapeHTML(isStation ? dict.suggestionStation : dict.suggestionVehicle)}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
box.innerHTML = html;
|
||||
}
|
||||
|
||||
function openExploreSuggestions() {
|
||||
const box = document.getElementById('exploreSuggestions');
|
||||
if (!box) return;
|
||||
box.hidden = false;
|
||||
if (currentMode === 'vehicle' && !vehicleExploreCatalog.ready) {
|
||||
void ensureVehicleExploreCatalog().then(() => renderExploreSuggestions());
|
||||
}
|
||||
renderExploreSuggestions();
|
||||
}
|
||||
|
||||
function closeExploreSuggestions() {
|
||||
const box = document.getElementById('exploreSuggestions');
|
||||
if (box) box.hidden = true;
|
||||
activeExploreSuggestionIndex = -1;
|
||||
}
|
||||
|
||||
async function selectExploreLocation(option) {
|
||||
filterState.query = '';
|
||||
if (currentMode === 'station') {
|
||||
filterState.province = option.province || '';
|
||||
filterState.city = option.city || '';
|
||||
filterState.district = option.district || '';
|
||||
} else {
|
||||
vehicleFilterGroups = {
|
||||
provinces: vehicleExploreCatalog.provinces.length ? vehicleExploreCatalog.provinces : vehicleFilterGroups.provinces,
|
||||
cities: vehicleExploreCatalog.cities.length ? vehicleExploreCatalog.cities : vehicleFilterGroups.cities,
|
||||
districts: vehicleExploreCatalog.districts.length ? vehicleExploreCatalog.districts : vehicleFilterGroups.districts
|
||||
};
|
||||
filterState.province = option.provinceGroup?.adcode || '';
|
||||
filterState.city = option.cityGroup?.adcode || '';
|
||||
filterState.district = option.districtGroup?.adcode || '';
|
||||
}
|
||||
closeExploreSuggestions();
|
||||
updateFilterToolbar();
|
||||
focusCurrentRegion(option.level);
|
||||
applyActiveFilters();
|
||||
void syncFilterControls(dashboard);
|
||||
}
|
||||
|
||||
function selectExploreEntity(item) {
|
||||
const entity = item.entity;
|
||||
if (!entity) return;
|
||||
closeExploreSuggestions();
|
||||
const coordinates = item.mode === 'vehicle'
|
||||
? entity.locationAvailable ? wgs84ToGcj02(Number(entity.longitude), Number(entity.latitude)) : null
|
||||
: [Number(entity.longitude), Number(entity.latitude)];
|
||||
if (coordinates?.every(Number.isFinite)) map?.setZoomAndCenter(14, coordinates);
|
||||
showEntityDetails(item.mode, entity);
|
||||
}
|
||||
|
||||
function selectExploreSuggestion(index) {
|
||||
const item = exploreSuggestionItems[index];
|
||||
if (!item) return;
|
||||
if (item.type === 'location') void selectExploreLocation(item);
|
||||
else selectExploreEntity(item);
|
||||
}
|
||||
|
||||
function handleExploreSearchKeydown(event) {
|
||||
if (event.key === 'Escape') { closeExploreSuggestions(); event.currentTarget.blur(); return; }
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return;
|
||||
const box = document.getElementById('exploreSuggestions');
|
||||
if (box?.hidden) openExploreSuggestions();
|
||||
if (event.key === 'Enter' && activeExploreSuggestionIndex >= 0) {
|
||||
event.preventDefault(); selectExploreSuggestion(activeExploreSuggestionIndex); return;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1;
|
||||
activeExploreSuggestionIndex = (activeExploreSuggestionIndex + direction + exploreSuggestionItems.length) % Math.max(exploreSuggestionItems.length, 1);
|
||||
renderExploreSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
function selectedLocationLabel(level) {
|
||||
if (currentMode === 'station') {
|
||||
const raw = filterState[level];
|
||||
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
|
||||
}
|
||||
const group = level === 'province' ? vehicleFilterGroups.provinces.find(item => item.adcode === filterState.province)
|
||||
: level === 'city' ? vehicleFilterGroups.cities.find(item => item.adcode === filterState.city)
|
||||
: vehicleFilterGroups.districts.find(item => item.adcode === filterState.district);
|
||||
return group ? groupLocationName(group, level) : '';
|
||||
}
|
||||
|
||||
function renderFilterChips() {
|
||||
const rail = document.getElementById('filterChipRail');
|
||||
if (!rail) return;
|
||||
const levels = ['province', 'city', 'district'].filter(level => filterState[level]);
|
||||
rail.hidden = !levels.length;
|
||||
rail.innerHTML = levels.map(level => `<span class="filter-chip">${escapeHTML(selectedLocationLabel(level))}<button type="button" onclick="clearLocationChip('${level}')" aria-label="清除${escapeHTML(selectedLocationLabel(level))}">×</button></span>`).join('');
|
||||
}
|
||||
|
||||
function clearLocationChip(level) {
|
||||
filterState[level] = '';
|
||||
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
|
||||
if (level === 'city') filterState.district = '';
|
||||
void syncFilterControls(dashboard);
|
||||
applyActiveFilters();
|
||||
}
|
||||
|
||||
function updateFilterToolbar() {
|
||||
const dict = i18n[currentLang];
|
||||
const search = document.getElementById('entitySearch');
|
||||
const searchClear = document.getElementById('searchClearBtn');
|
||||
const meta = document.getElementById('filterResultMeta');
|
||||
if (search) {
|
||||
search.placeholder = dict.searchUniversal;
|
||||
search.setAttribute('aria-label', dict.searchUniversal);
|
||||
if (search.value !== filterState.query) search.value = filterState.query;
|
||||
}
|
||||
if (searchClear) searchClear.hidden = !normalizeSearch(filterState.query);
|
||||
const all = currentMode === 'vehicle' ? dashboard?.vehicles?.length || 0 : dashboard?.stations?.length || 0;
|
||||
const filtered = currentMode === 'vehicle' ? filteredVehicles().length : filteredStations().length;
|
||||
if (meta) meta.textContent = isFilterActive() ? `${dict.filterResult} ${formatNumber(filtered)} / ${formatNumber(all)}` : `${dict.viewportAll} ${formatNumber(all)}`;
|
||||
renderFilterChips();
|
||||
renderExploreSuggestions();
|
||||
}
|
||||
|
||||
function focusCoordinates(items, zoom) {
|
||||
const located = items.filter(item => Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude)));
|
||||
if (!map || !located.length) return;
|
||||
const center = located.reduce((sum, item) => [sum[0] + Number(item.longitude), sum[1] + Number(item.latitude)], [0, 0]);
|
||||
map.setZoomAndCenter(zoom, [center[0] / located.length, center[1] / located.length]);
|
||||
}
|
||||
|
||||
function focusCurrentRegion(level) {
|
||||
if (!map) return;
|
||||
if (currentMode === 'vehicle') {
|
||||
const group = selectedVehicleFilterGroup();
|
||||
if (group?.lnglat) map.setZoomAndCenter(level === 'province' ? 7.4 : level === 'city' ? 10 : 12.5, group.lnglat);
|
||||
return;
|
||||
}
|
||||
const stations = filteredStations();
|
||||
if (stations.length) focusCoordinates(stations, level === 'province' ? 7.4 : level === 'city' ? 10.5 : 13);
|
||||
}
|
||||
|
||||
function focusUniqueQueryResult() {
|
||||
if (!normalizeSearch(filterState.query)) return;
|
||||
const matches = currentMode === 'vehicle' ? filteredVehicles() : filteredStations();
|
||||
if (matches.length === 1) {
|
||||
const item = matches[0];
|
||||
if (Number.isFinite(Number(item.longitude)) && Number.isFinite(Number(item.latitude))) {
|
||||
const coordinates = currentMode === 'vehicle'
|
||||
? wgs84ToGcj02(Number(item.longitude), Number(item.latitude))
|
||||
: [Number(item.longitude), Number(item.latitude)];
|
||||
map?.setZoomAndCenter(14, coordinates);
|
||||
}
|
||||
showEntityDetails(currentMode, item);
|
||||
}
|
||||
}
|
||||
|
||||
function applyActiveFilters({ focusQuery = false } = {}) {
|
||||
closeEntityDetails();
|
||||
updateFilterToolbar();
|
||||
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||
else refreshStationViewport();
|
||||
if (focusQuery) window.setTimeout(focusUniqueQueryResult, 0);
|
||||
}
|
||||
|
||||
function handleExploreInput(value) {
|
||||
filterState.query = value;
|
||||
openExploreSuggestions();
|
||||
updateFilterToolbar();
|
||||
window.clearTimeout(filterDebounceTimer);
|
||||
filterDebounceTimer = window.setTimeout(() => applyActiveFilters({ focusQuery: true }), 180);
|
||||
}
|
||||
|
||||
function handleNameFilterInput(value) { handleExploreInput(value); }
|
||||
|
||||
function clearNameFilter() {
|
||||
filterState.query = '';
|
||||
closeExploreSuggestions();
|
||||
applyActiveFilters();
|
||||
}
|
||||
|
||||
async function handleRegionFilterChange(level, value) {
|
||||
filterState[level] = value;
|
||||
if (level === 'province') { filterState.city = ''; filterState.district = ''; }
|
||||
if (level === 'city') filterState.district = '';
|
||||
await syncFilterControls(dashboard);
|
||||
focusCurrentRegion(level);
|
||||
applyActiveFilters();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
|
||||
closeExploreSuggestions();
|
||||
closeEntityDetails();
|
||||
resetMapView();
|
||||
void syncFilterControls(dashboard);
|
||||
applyActiveFilters();
|
||||
}
|
||||
|
||||
function initAMapInstance() {
|
||||
if (typeof AMap === 'undefined') {
|
||||
setDataState('error');
|
||||
@@ -123,7 +649,7 @@ function initAMapInstance() {
|
||||
zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30,
|
||||
mapStyle: getAMapThemeStyle(currentTheme), showBuildingBlock: false, showLabel: true
|
||||
});
|
||||
map.on('complete', renderAMapMarkers);
|
||||
map.on('complete', () => { renderAMapMarkers(); renderUserLocationMarker(); });
|
||||
map.on('zoomend', () => {
|
||||
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||
else refreshStationViewport();
|
||||
@@ -165,6 +691,7 @@ function updateDashboardUI() {
|
||||
document.getElementById('mapStatusText').textContent = mapStatusSummaryText(summary, regionSummary, dashboard.asOf);
|
||||
updateStatusPanel();
|
||||
updateRankingControls();
|
||||
updateFilterToolbar();
|
||||
renderRankingList(currentRankType);
|
||||
renderAMapMarkers();
|
||||
}
|
||||
@@ -450,7 +977,7 @@ function regionNodesFromGroups(groups, level) {
|
||||
})).sort((left, right) => right.count - left.count || left.nameZh.localeCompare(right.nameZh, 'zh-CN'));
|
||||
}
|
||||
|
||||
async function aggregateVehicleHierarchy(level, vehicles = dashboard?.vehicles || []) {
|
||||
async function aggregateVehicleHierarchy(level, vehicles = filteredVehicles()) {
|
||||
const located = locatedVehicles(vehicles);
|
||||
let unassigned = vehicles.length - located.length;
|
||||
const nationalNode = await areaNodeFor(100000);
|
||||
@@ -473,7 +1000,7 @@ function currentMapBounds() {
|
||||
return { west, south, east, north };
|
||||
}
|
||||
|
||||
function vehiclePointNodes(vehicles = dashboard?.vehicles || []) {
|
||||
function vehiclePointNodes(vehicles = filteredVehicles()) {
|
||||
const bounds = currentMapBounds();
|
||||
return locatedVehicles(vehicles).map(vehicle => {
|
||||
const lnglat = wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude));
|
||||
@@ -497,19 +1024,21 @@ function nodesInCurrentView(nodes, level) {
|
||||
|
||||
async function refreshVehicleRegionNodes(sourceDashboard = dashboard) {
|
||||
if (!sourceDashboard) return;
|
||||
const level = hierarchyLevelForZoom();
|
||||
const level = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
|
||||
const version = ++regionAggregationVersion;
|
||||
const unassigned = (sourceDashboard.vehicles || []).length - locatedVehicles(sourceDashboard.vehicles || []).length;
|
||||
const vehicles = filteredVehicles(sourceDashboard.vehicles || []);
|
||||
const unassigned = vehicles.length - locatedVehicles(vehicles).length;
|
||||
if (level === 'vehicle') {
|
||||
vehicleRegionSummary = { level, nodes: vehiclePointNodes(sourceDashboard.vehicles || []), unassigned, loading: false };
|
||||
vehicleRegionSummary = { level, nodes: vehiclePointNodes(vehicles), unassigned, loading: false };
|
||||
updateDashboardUI();
|
||||
return;
|
||||
}
|
||||
vehicleRegionSummary = { level, nodes: [], unassigned, loading: true };
|
||||
updateDashboardUI();
|
||||
try {
|
||||
const result = await aggregateVehicleHierarchy(level, sourceDashboard.vehicles || []);
|
||||
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== hierarchyLevelForZoom()) return;
|
||||
const result = await aggregateVehicleHierarchy(level, vehicles);
|
||||
const currentLevel = normalizeSearch(filterState.query) ? 'vehicle' : hierarchyLevelForZoom();
|
||||
if (version !== regionAggregationVersion || sourceDashboard !== dashboard || level !== currentLevel) return;
|
||||
result.nodes = nodesInCurrentView(result.nodes, level);
|
||||
vehicleRegionSummary = result;
|
||||
} catch (error) {
|
||||
@@ -530,6 +1059,7 @@ function buildVehicleRegionNodes() {
|
||||
function vehicleNodes() { return buildVehicleRegionNodes().nodes; }
|
||||
|
||||
function stationHierarchyLevelForZoom(zoom = map?.getZoom?.() || 0) {
|
||||
if (normalizeSearch(filterState.query)) return 'station';
|
||||
if (zoom < 7) return 'province';
|
||||
if (zoom < 11) return 'city';
|
||||
return 'station';
|
||||
@@ -542,12 +1072,13 @@ function stationGroupName(station, level) {
|
||||
return level === 'province' ? compactProvinceName(raw) : compactRegionName(raw);
|
||||
}
|
||||
|
||||
function buildStationNodes(stations = dashboard?.stations || [], level = stationHierarchyLevelForZoom()) {
|
||||
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevelForZoom()) {
|
||||
if (level === 'station') {
|
||||
return stations.map(station => ({
|
||||
id: `station-${station.id}`, kind: 'station', name: station.name, lnglat: [station.longitude, station.latitude],
|
||||
station,
|
||||
count: 1, online: station.cooperative ? 1 : 0,
|
||||
adminPath: stationAdministrativePath(station),
|
||||
adminPath: [stationAdministrativePath(station), stationDistrictName(station)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · '),
|
||||
detail: [stationAdministrativePath(station), currentLang === 'zh' ? station.address : ''].filter(Boolean).join(' · '),
|
||||
cooperative: station.cooperative, hydrogenKg: Number(station.totalHydrogenKg) || 0,
|
||||
hasHydrogen: station.totalHydrogenKg != null
|
||||
@@ -609,6 +1140,126 @@ function markerLabel(node, dict = i18n[currentLang]) {
|
||||
return countLabel(node.count, 'vehicle', dict);
|
||||
}
|
||||
|
||||
function entityKey(mode, entity) {
|
||||
return mode === 'vehicle' ? `vehicle:${entity?.vin || ''}` : `station:${entity?.id || ''}`;
|
||||
}
|
||||
|
||||
function nodeEntity(node) {
|
||||
if (node?.kind === 'vehiclePoint') return { mode: 'vehicle', entity: node.vehicle || node.vehicles?.[0] };
|
||||
if (node?.kind === 'station') return { mode: 'station', entity: node.station };
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNodeSelected(node) {
|
||||
const target = nodeEntity(node);
|
||||
return Boolean(target?.entity && selectedEntity?.key === entityKey(target.mode, target.entity));
|
||||
}
|
||||
|
||||
function detailValue(value, suffix = '') {
|
||||
if (value == null || value === '') return i18n[currentLang].valueUnavailable;
|
||||
return `${value}${suffix}`;
|
||||
}
|
||||
|
||||
function detailField(label, value, wide = false, extra = false) {
|
||||
return `<div class="detail-field${wide ? ' is-wide' : ''}${extra ? ' is-extra' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`;
|
||||
}
|
||||
|
||||
function vehicleStatusLabel(vehicle) {
|
||||
const status = vehicleActiveToday(vehicle)
|
||||
? Number(vehicle?.speedKmh || 0) > 3 ? i18n[currentLang].statusRunning : i18n[currentLang].statusStopped
|
||||
: i18n[currentLang].statusOffline;
|
||||
return status;
|
||||
}
|
||||
|
||||
function showEntityDetails(mode, entity) {
|
||||
if (!entity) return;
|
||||
const dict = i18n[currentLang];
|
||||
selectedEntity = { mode, entity, key: entityKey(mode, entity) };
|
||||
const card = document.getElementById('mapDetailCard');
|
||||
const type = document.getElementById('detailTypePill');
|
||||
const accent = document.getElementById('detailCardAccent');
|
||||
const title = document.getElementById('detailTitle');
|
||||
const subtitle = document.getElementById('detailSubtitle');
|
||||
const grid = document.getElementById('detailGrid');
|
||||
const expand = document.getElementById('detailExpandBtn');
|
||||
if (!card || !type || !accent || !title || !subtitle || !grid || !expand) return;
|
||||
detailExpanded = false;
|
||||
card.classList.remove('is-expanded');
|
||||
const isStation = mode === 'station';
|
||||
type.textContent = isStation ? dict.detailStation : dict.detailVehicle;
|
||||
type.classList.toggle('is-station', isStation);
|
||||
accent.classList.toggle('is-station', isStation);
|
||||
if (isStation) {
|
||||
title.textContent = entity.name || entity.shortName || dict.detailStation;
|
||||
subtitle.textContent = entity.shortName && entity.shortName !== entity.name ? entity.shortName : stationAdministrativePath(entity);
|
||||
const admin = [stationAdministrativePath(entity), stationDistrictName(entity)].filter(Boolean).filter((value, index, items) => index === 0 || !items[0].includes(value)).join(' · ');
|
||||
grid.innerHTML = [
|
||||
detailField(dict.detailStationType, entity.cooperative ? dict.stationCooperative : dict.stationExternal),
|
||||
detailField(dict.detailAdmin, detailValue(admin)),
|
||||
detailField(dict.detailAddress, detailValue(entity.address), true),
|
||||
detailField(dict.detailMonthlyHydrogen, entity.monthlyHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.monthlyHydrogenKg, 1)} kg`),
|
||||
detailField(dict.detailTotalHydrogen, entity.totalHydrogenKg == null ? dict.valueUnavailable : `${formatNumber(entity.totalHydrogenKg, 1)} kg`, false, true),
|
||||
detailField(dict.detailCoordinate, `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}`, true, true)
|
||||
].join('');
|
||||
} else {
|
||||
title.textContent = entity.plateNumber || entity.vin || dict.detailVehicle;
|
||||
subtitle.textContent = entity.vin || '';
|
||||
const hasLocation = entity.locationAvailable && Number.isFinite(Number(entity.longitude)) && Number.isFinite(Number(entity.latitude));
|
||||
grid.innerHTML = [
|
||||
detailField(dict.detailPlate, detailValue(entity.plateNumber), false, true),
|
||||
detailField(dict.detailVin, detailValue(entity.vin), false, true),
|
||||
detailField(dict.detailStatus, vehicleStatusLabel(entity)),
|
||||
detailField(dict.detailActive, vehicleActiveToday(entity) ? dict.valueYes : dict.valueNo),
|
||||
detailField(dict.detailSpeed, entity.speedKmh == null ? dict.valueUnavailable : `${formatNumber(entity.speedKmh, 1)} km/h`),
|
||||
detailField(dict.detailDailyMileage, `${formatNumber(entity.dailyMileageKm, 1)} km`),
|
||||
detailField(dict.detailTotalMileage, entity.totalMileageKm == null ? dict.valueUnavailable : `${formatNumber(entity.totalMileageKm, 1)} km`, false, true),
|
||||
detailField(dict.detailLocation, hasLocation ? dict.valueYes : dict.valueUnavailable, false, true),
|
||||
detailField(dict.detailCoordinate, hasLocation ? `${formatNumber(entity.longitude, 6)}, ${formatNumber(entity.latitude, 6)}` : dict.valueUnavailable, true, true)
|
||||
].join('');
|
||||
}
|
||||
expand.hidden = false;
|
||||
expand.textContent = dict.detailMore;
|
||||
card.hidden = false;
|
||||
renderAMapMarkers();
|
||||
renderRankingList(currentRankType);
|
||||
}
|
||||
|
||||
function toggleEntityDetails() {
|
||||
const card = document.getElementById('mapDetailCard');
|
||||
const expand = document.getElementById('detailExpandBtn');
|
||||
if (!card || !expand || !selectedEntity) return;
|
||||
detailExpanded = !detailExpanded;
|
||||
card.classList.toggle('is-expanded', detailExpanded);
|
||||
expand.textContent = detailExpanded ? i18n[currentLang].detailLess : i18n[currentLang].detailMore;
|
||||
}
|
||||
|
||||
function showNodeDetails(node) {
|
||||
const target = nodeEntity(node);
|
||||
if (target?.entity) showEntityDetails(target.mode, target.entity);
|
||||
}
|
||||
|
||||
function closeEntityDetails() {
|
||||
selectedEntity = null;
|
||||
detailExpanded = false;
|
||||
const card = document.getElementById('mapDetailCard');
|
||||
if (card) card.hidden = true;
|
||||
if (dashboard) {
|
||||
renderAMapMarkers();
|
||||
renderRankingList(currentRankType);
|
||||
}
|
||||
}
|
||||
|
||||
function distanceKm(origin, target) {
|
||||
if (!origin || !target) return Number.POSITIVE_INFINITY;
|
||||
const toRadians = degrees => degrees * Math.PI / 180;
|
||||
const deltaLat = toRadians(Number(target[1]) - Number(origin[1]));
|
||||
const deltaLng = toRadians(Number(target[0]) - Number(origin[0]));
|
||||
const lat1 = toRadians(Number(origin[1]));
|
||||
const lat2 = toRadians(Number(target[1]));
|
||||
const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2;
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function renderAMapMarkers() {
|
||||
if (!map || !dashboard) return;
|
||||
markerList.forEach(marker => marker.remove()); markerList = [];
|
||||
@@ -622,7 +1273,7 @@ function renderAMapMarkers() {
|
||||
markerContent.className = 'province-info-badge-wrap';
|
||||
const label = markerLabel(node, dict);
|
||||
const subline = node.kind === 'vehiclePoint' ? `${formatNumber(node.speedKmh, 1)} km/h${node.protocol ? ` · ${node.protocol}` : ''}` : isStationPoint ? node.adminPath : `${dict.onlineText} ${node.online}`;
|
||||
markerContent.innerHTML = `<div class="province-info-card${node.kind === 'vehiclePoint' ? ` is-vehicle-point ${node.online ? 'is-online' : 'is-offline'}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${(isVehicleNode || isStationPoint) && subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||||
markerContent.innerHTML = `<div class="province-info-card${node.kind === 'vehiclePoint' ? ` is-vehicle-point ${node.online ? 'is-online' : 'is-offline'}` : ''}${isNodeSelected(node) ? ' is-selected' : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span><span class="p-total">${escapeHTML(label)}</span></div>${(isVehicleNode || isStationPoint) && subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||||
const marker = new AMap.Marker({ position: node.lnglat, content: markerContent, offset: new AMap.Pixel(-30, -12), title: node.name });
|
||||
const infoWindow = new AMap.InfoWindow({
|
||||
isCustom: true,
|
||||
@@ -637,6 +1288,7 @@ function renderAMapMarkers() {
|
||||
if (node.kind === 'vehicleDistrict') marker.on('click', () => map.setZoomAndCenter(12.2, node.lnglat));
|
||||
if (node.kind === 'stationProvince' || node.kind === 'stationCluster') marker.on('click', () => map.setZoomAndCenter(7.2, node.lnglat));
|
||||
if (node.kind === 'stationCity') marker.on('click', () => map.setZoomAndCenter(11.2, node.lnglat));
|
||||
if (node.kind === 'vehiclePoint' || node.kind === 'station') marker.on('click', () => showNodeDetails(node));
|
||||
marker.setMap(map); markerList.push(marker);
|
||||
}
|
||||
}
|
||||
@@ -647,26 +1299,48 @@ function renderRankingList(type) {
|
||||
box.innerHTML = '';
|
||||
let rows;
|
||||
if (currentMode === 'vehicle') {
|
||||
rows = buildVehicleRegionNodes().nodes
|
||||
.sort((a, b) => type === 'secondary' ? b.dist - a.dist : b.count - a.count)
|
||||
.slice(0, 12)
|
||||
.map(item => ({
|
||||
name: item.name,
|
||||
value: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
|
||||
location: item.lnglat,
|
||||
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14
|
||||
}));
|
||||
if (normalizeSearch(filterState.query)) {
|
||||
rows = filteredVehicles()
|
||||
.sort((left, right) => Number(vehicleActiveToday(right)) - Number(vehicleActiveToday(left)) || String(left.plateNumber || left.vin).localeCompare(String(right.plateNumber || right.vin), 'zh-CN'))
|
||||
.map(vehicle => {
|
||||
const hasLocation = vehicle.locationAvailable && Number.isFinite(Number(vehicle.longitude)) && Number.isFinite(Number(vehicle.latitude));
|
||||
const location = hasLocation ? wgs84ToGcj02(Number(vehicle.longitude), Number(vehicle.latitude)) : null;
|
||||
return {
|
||||
name: vehicle.plateNumber || vehicle.vin,
|
||||
value: userLocation && location ? `${formatNumber(distanceKm(userLocation, location), 1)} km` : vehicleStatusLabel(vehicle),
|
||||
location, zoom: 14, entity: vehicle, mode: 'vehicle'
|
||||
};
|
||||
});
|
||||
} else {
|
||||
rows = buildVehicleRegionNodes().nodes
|
||||
.sort((a, b) => userLocation && a.kind === 'vehiclePoint'
|
||||
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
|
||||
: type === 'secondary' ? b.dist - a.dist : b.count - a.count)
|
||||
.slice(0, 100)
|
||||
.map(item => ({
|
||||
name: item.name,
|
||||
value: userLocation && item.kind === 'vehiclePoint' ? `${formatNumber(distanceKm(userLocation, item.lnglat), 1)} km`
|
||||
: type === 'secondary' ? `${formatNumber(item.dist, 1)} km` : item.kind === 'vehiclePoint' ? `${formatNumber(item.speedKmh, 1)} km/h` : countLabel(item.count, 'vehicle'),
|
||||
location: item.lnglat,
|
||||
zoom: item.kind === 'vehicleProvince' ? 7.4 : item.kind === 'vehicleCity' ? 9.8 : item.kind === 'vehicleDistrict' ? 12.2 : 14,
|
||||
node: item, entity: item.kind === 'vehiclePoint' ? item.vehicle || item.vehicles?.[0] : null, mode: 'vehicle'
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
const { level, allNodes, visibleNodes } = stationViewportSummary();
|
||||
const byHydrogen = type === 'secondary';
|
||||
rows = [...visibleNodes]
|
||||
.filter(node => !byHydrogen || node.hasHydrogen)
|
||||
.sort((a, b) => byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
.sort((a, b) => userLocation && level === 'station'
|
||||
? distanceKm(userLocation, a.lnglat) - distanceKm(userLocation, b.lnglat)
|
||||
: byHydrogen ? b.hydrogenKg - a.hydrogenKg : b.count - a.count || a.name.localeCompare(b.name, 'zh-CN'))
|
||||
.map(node => ({
|
||||
name: node.name,
|
||||
value: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
|
||||
value: userLocation && level === 'station' ? `${formatNumber(distanceKm(userLocation, node.lnglat), 1)} km`
|
||||
: byHydrogen ? `${formatNumber(node.hydrogenKg, 1)} kg` : countLabel(node.count, 'station'),
|
||||
location: node.lnglat,
|
||||
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14
|
||||
zoom: level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14,
|
||||
node, entity: node.kind === 'station' ? node.station : null, mode: 'station'
|
||||
}));
|
||||
updateStationViewportMeta(visibleNodes.length, allNodes.length);
|
||||
if (!rows.length) {
|
||||
@@ -675,9 +1349,13 @@ function renderRankingList(type) {
|
||||
}
|
||||
}
|
||||
rows.forEach((item, index) => {
|
||||
const row = document.createElement('div'); row.className = 'rank-glass-row';
|
||||
const row = document.createElement('div');
|
||||
row.className = `rank-glass-row${item.entity && selectedEntity?.key === entityKey(item.mode, item.entity) ? ' is-selected' : ''}`;
|
||||
row.innerHTML = `<span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${escapeHTML(item.name)}</span><span class="r-val">${escapeHTML(item.value)}</span>`;
|
||||
if (item.location) row.onclick = () => map?.setZoomAndCenter(item.zoom || 9, item.location);
|
||||
row.onclick = () => {
|
||||
if (item.location) map?.setZoomAndCenter(item.zoom || 9, item.location);
|
||||
if (item.entity) showEntityDetails(item.mode, item.entity);
|
||||
};
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -697,7 +1375,9 @@ function updateStationViewportMeta(visibleCount, totalCount) {
|
||||
function updateRankingControls() {
|
||||
const dict = i18n[currentLang];
|
||||
const stationLevel = stationHierarchyLevelForZoom();
|
||||
const title = currentMode === 'vehicle'
|
||||
const title = userLocation && ((currentMode === 'vehicle' && vehicleRegionSummary.level === 'vehicle') || (currentMode === 'station' && stationLevel === 'station'))
|
||||
? (currentMode === 'vehicle' ? dict.nearbyVehicles : dict.nearbyStations)
|
||||
: currentMode === 'vehicle'
|
||||
? dict.panelRankVehicle
|
||||
: stationLevel === 'province' ? dict.panelRankStation : stationLevel === 'city'
|
||||
? (currentLang === 'zh' ? '加氢站城市 TOP 排名' : 'Top Cities by Stations')
|
||||
@@ -717,15 +1397,23 @@ function switchRankTab(type) {
|
||||
|
||||
function switchMode(mode) {
|
||||
currentMode = mode;
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
|
||||
closeExploreSuggestions();
|
||||
selectedEntity = null;
|
||||
const detailCard = document.getElementById('mapDetailCard');
|
||||
if (detailCard) detailCard.hidden = true;
|
||||
document.getElementById('btnModeVehicle').classList.toggle('active', mode === 'vehicle');
|
||||
document.getElementById('btnModeStation').classList.toggle('active', mode === 'station');
|
||||
currentRankType = 'primary';
|
||||
void syncFilterControls(dashboard);
|
||||
updateLocateButton(userLocation ? 'active' : 'idle');
|
||||
updateDashboardUI();
|
||||
if (mode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||
}
|
||||
|
||||
function setLanguage(lang) {
|
||||
currentLang = lang;
|
||||
vehicleExploreCatalog = { ready: false, loading: null, options: [], provinces: [], cities: [], districts: [] };
|
||||
document.querySelectorAll('.lang-switcher-bw .segment-btn').forEach(button => button.classList.remove('active'));
|
||||
document.querySelector(`.lang-${lang}-btn`)?.classList.add('active');
|
||||
const dict = i18n[lang];
|
||||
@@ -740,7 +1428,10 @@ function setLanguage(lang) {
|
||||
brandLogoEnglish.hidden = !english;
|
||||
brandLogo.alt = '羚牛氢能';
|
||||
}
|
||||
void syncFilterControls(dashboard);
|
||||
updateLocateButton(userLocation ? 'active' : 'idle');
|
||||
updateDashboardUI();
|
||||
if (selectedEntity?.entity) showEntityDetails(selectedEntity.mode, selectedEntity.entity);
|
||||
}
|
||||
|
||||
function setTheme(themeName) {
|
||||
@@ -752,8 +1443,84 @@ function setTheme(themeName) {
|
||||
|
||||
function getAMapThemeStyle(theme) { return theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'; }
|
||||
function resetMapView() { if (map) { map.setZoomAndCenter(4.8, [108.948024, 34.263161]); map.setPitch(30); } }
|
||||
function clearProvinceFilter() { resetMapView(); }
|
||||
function clearProvinceFilter() { clearAllFilters(); }
|
||||
function togglePitchView() { if (map) { is3DPitch = !is3DPitch; map.setPitch(is3DPitch ? 30 : 0); } }
|
||||
|
||||
function locationCoordinates(position) {
|
||||
const longitude = Number(position?.coords?.longitude);
|
||||
const latitude = Number(position?.coords?.latitude);
|
||||
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null;
|
||||
return wgs84ToGcj02(longitude, latitude);
|
||||
}
|
||||
|
||||
function updateLocateButton(state) {
|
||||
const button = document.getElementById('locateDeviceBtn');
|
||||
const label = document.getElementById('locateDeviceLabel');
|
||||
if (!button || !label) return;
|
||||
const dict = i18n[currentLang];
|
||||
button.classList.toggle('is-loading', state === 'loading');
|
||||
button.classList.toggle('is-active', state === 'active');
|
||||
button.classList.toggle('is-error', state === 'error');
|
||||
button.disabled = state === 'loading';
|
||||
label.textContent = state === 'loading' ? dict.locating : state === 'active' ? dict.located : state === 'error' ? dict.locateFailed : dict.btnLocate;
|
||||
}
|
||||
|
||||
function renderUserLocationMarker() {
|
||||
if (!map || !userLocation || typeof AMap === 'undefined') return;
|
||||
if (userLocationMarker) userLocationMarker.remove();
|
||||
const content = document.createElement('div'); content.className = 'user-location-marker';
|
||||
userLocationMarker = new AMap.Marker({
|
||||
position: userLocation,
|
||||
content,
|
||||
offset: new AMap.Pixel(-9, -9),
|
||||
title: currentLang === 'zh' ? '我的位置' : 'My location',
|
||||
zIndex: 300
|
||||
});
|
||||
userLocationMarker.setMap(map);
|
||||
}
|
||||
|
||||
function handleLocationSuccess(position) {
|
||||
const coordinates = locationCoordinates(position);
|
||||
if (!coordinates) { handleLocationError(); return; }
|
||||
filterState.province = ''; filterState.city = ''; filterState.district = ''; filterState.query = '';
|
||||
selectedEntity = null;
|
||||
const detailCard = document.getElementById('mapDetailCard');
|
||||
if (detailCard) detailCard.hidden = true;
|
||||
void syncFilterControls(dashboard);
|
||||
const feedback = document.getElementById('locationFeedback');
|
||||
if (feedback) feedback.hidden = true;
|
||||
userLocation = coordinates;
|
||||
updateLocateButton('active');
|
||||
renderUserLocationMarker();
|
||||
map?.setZoomAndCenter(13, coordinates);
|
||||
const hint = document.getElementById('selectedRegionHint');
|
||||
if (hint) hint.textContent = currentLang === 'zh' ? '视角: 我的位置附近' : 'View: Near my location';
|
||||
if (currentMode === 'vehicle') refreshVehicleRegionNodes(dashboard);
|
||||
else refreshStationViewport();
|
||||
}
|
||||
|
||||
function handleLocationError(error) {
|
||||
const dict = i18n[currentLang];
|
||||
const feedback = document.getElementById('locationFeedback');
|
||||
if (feedback) {
|
||||
feedback.textContent = error?.code === 1 ? dict.locatePermissionHint : error?.code === 3 ? dict.locateTimeoutHint : dict.locateUnavailableHint;
|
||||
feedback.hidden = false;
|
||||
window.setTimeout(() => { feedback.hidden = true; }, 5200);
|
||||
}
|
||||
updateLocateButton('error');
|
||||
window.setTimeout(() => updateLocateButton(userLocation ? 'active' : 'idle'), 2400);
|
||||
}
|
||||
|
||||
function locateDevice() {
|
||||
if (!navigator.geolocation) { handleLocationError(); return; }
|
||||
updateLocateButton('loading');
|
||||
navigator.geolocation.getCurrentPosition(handleLocationSuccess, handleLocationError, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 30000
|
||||
});
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString(currentLang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
||||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||||
function initClock() {
|
||||
|
||||
+40
-2
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css?v=20260804044000">
|
||||
<link rel="stylesheet" href="styles.css?v=20260812113000">
|
||||
|
||||
<!-- 高德地图 (AutoNavi AMap) Web JS API v2.0 Configuration -->
|
||||
<script type="text/javascript">
|
||||
@@ -114,10 +114,48 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="map-explore-toolbar" aria-label="地图筛选与定位">
|
||||
<button class="locate-btn" id="locateDeviceBtn" type="button" onclick="locateDevice()" aria-label="定位到我的位置" title="定位到我的位置">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"></path>
|
||||
<circle cx="12" cy="12" r="8"></circle>
|
||||
</svg>
|
||||
<span id="locateDeviceLabel" data-i18n="btnLocate">定位</span>
|
||||
</button>
|
||||
|
||||
<div class="explore-search-shell">
|
||||
<label class="entity-search-field" for="entitySearch">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"></circle><path d="m20 20-3.6-3.6"></path></svg>
|
||||
<input id="entitySearch" type="search" autocomplete="off" placeholder="搜索位置、站点、车牌或 VIN" aria-label="搜索位置、站点、车牌或 VIN" oninput="handleExploreInput(this.value)" onfocus="openExploreSuggestions()" onkeydown="handleExploreSearchKeydown(event)">
|
||||
<button class="search-clear-btn" id="searchClearBtn" type="button" onclick="clearNameFilter()" aria-label="清除名称筛选" hidden>×</button>
|
||||
</label>
|
||||
<div class="explore-suggestions" id="exploreSuggestions" role="listbox" aria-label="搜索建议" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="filter-chip-rail" id="filterChipRail" aria-label="当前筛选" hidden></div>
|
||||
<span class="filter-result-meta" id="filterResultMeta" aria-live="polite">全部 0</span>
|
||||
<span class="location-feedback" id="locationFeedback" role="status" hidden></span>
|
||||
</div>
|
||||
|
||||
<!-- AMap 3D Canvas -->
|
||||
<div class="amap-canvas-box">
|
||||
<div id="amapContainer"></div>
|
||||
|
||||
<article class="map-detail-card" id="mapDetailCard" aria-live="polite" hidden>
|
||||
<div class="detail-card-accent" id="detailCardAccent"></div>
|
||||
<div class="detail-card-header">
|
||||
<div class="detail-title-wrap">
|
||||
<span class="detail-type-pill" id="detailTypePill">车辆</span>
|
||||
<h2 id="detailTitle">—</h2>
|
||||
<p id="detailSubtitle"></p>
|
||||
</div>
|
||||
<button class="detail-close-btn" type="button" onclick="closeEntityDetails()" aria-label="关闭详情">×</button>
|
||||
</div>
|
||||
<dl class="detail-grid" id="detailGrid"></dl>
|
||||
<button class="detail-expand-btn" id="detailExpandBtn" type="button" onclick="toggleEntityDetails()" hidden>展开详情</button>
|
||||
</article>
|
||||
|
||||
<!-- Bottom Right Floating Controls -->
|
||||
<div class="map-action-controls">
|
||||
<button class="glass-btn" onclick="resetMapView()" data-i18n="btnResetView">复位视角</button>
|
||||
@@ -191,6 +229,6 @@
|
||||
</div>
|
||||
|
||||
<script src="node_modules/pinyin-pro/dist/index.js?v=3.28.2"></script>
|
||||
<script src="app.js?v=20260811170000"></script>
|
||||
<script src="app.js?v=20260812113000"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
Reference in New Issue
Block a user