feat(navigation): add multi-region picker

This commit is contained in:
lingniu
2026-08-12 13:40:34 +08:00
parent 5b565d57fc
commit c81cfcb377
4 changed files with 145 additions and 15 deletions
+57 -11
View File
@@ -1,4 +1,4 @@
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true };
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true, regionPicker: null };
document.addEventListener('DOMContentLoaded', () => { initClock(); initMap(); loadStations(); });
document.addEventListener('pointerdown', event => { if (!event.target?.closest?.('.map-explore-toolbar')) closeStationSearchSuggestions(); });
@@ -45,7 +45,7 @@ function stationMatchesLocation(station) { return !state.locationFilters.length
function stationTypeScope() { return state.stations.filter(station => state.stationFilter !== 'partner' || station.cooperative); }
function filteredStations() { return stationTypeScope().filter(station => stationMatchesLocation(station) && stationMatchesQuery(station)); }
function stationSearchScore(station, query) {
const fields = [station.name, station.shortName, station.province, station.city, station.district, station.address].filter(Boolean);
const fields = [station.name, station.shortName, station.address].filter(Boolean);
for (const value of fields) { const text = normalize(value); if (text.includes(query)) return 3; const pinyin = toPinyin(value); if (pinyin.includes(query) || initials(pinyin).includes(query)) return 2; }
return 0;
}
@@ -123,7 +123,8 @@ function stationLocationOptions() {
const add = (level, station) => {
const province = station.province || '', city = station.city || '', district = station.district || '';
const key = level === 'province' ? `province|${province}` : level === 'city' ? `city|${province}|${city}` : `district|${province}|${city}|${district}`;
if (!groups.has(key)) groups.set(key, { type: 'location', level, province, city, district, stations: [] });
const filter = level === 'province' ? { province, city: '', district: '' } : level === 'city' ? { province, city, district: '' } : { province, city, district };
if (!groups.has(key)) groups.set(key, { type: 'location', level, ...filter, stations: [] });
groups.get(key).stations.push(station);
};
for (const station of stationTypeScope()) { const district = stationDistrictName(station); if (station.province) add('province', station); if (station.province && station.city) add('city', station); if (station.province && station.city && district) add('district', { ...station, district }); }
@@ -145,13 +146,9 @@ function stationEntitySuggestions(query) {
}
function renderStationSearchSuggestions() {
const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return;
const locations = stationLocationSuggestions(state.searchText), stations = stationEntitySuggestions(state.searchText);
state.suggestions = [...locations, ...stations]; state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的位置或站点</span>'; return; }
let html = '';
if (locations.length) html += `<span class="suggestion-section-label">位置</span>${locations.map((item, index) => `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${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">地点</span></button>`).join('')}`;
if (stations.length) html += `<span class="suggestion-section-label">加氢站</span>${stations.map((item, offset) => { const index = locations.length + offset, station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(stationRegion(station) || station.address || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
box.innerHTML = html;
state.suggestions = stationEntitySuggestions(state.searchText); state.activeSuggestion = Math.min(state.activeSuggestion, state.suggestions.length - 1);
if (!state.suggestions.length) { box.innerHTML = '<span class="suggestion-empty">未找到匹配的站点或详细地址</span>'; return; }
box.innerHTML = `<span class="suggestion-section-label">加氢站</span>${state.suggestions.map((item, index) => { const station = item.station; return `<button type="button" class="explore-suggestion${index === state.activeSuggestion ? ' is-active' : ''}" role="option" onclick="selectStationSearchSuggestion(${index})"><span class="suggestion-symbol">H₂</span><span class="suggestion-copy"><strong>${escapeHTML(station.name || station.shortName || '未命名站点')}</strong><small>${escapeHTML(station.address || stationRegion(station) || '')}</small></span><span class="suggestion-kind">加氢站</span></button>`; }).join('')}`;
}
function openStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (!box) return; box.hidden = false; renderStationSearchSuggestions(); }
function closeStationSearchSuggestions() { const box = document.getElementById('stationSearchSuggestions'); if (box) box.hidden = true; state.activeSuggestion = -1; }
@@ -160,6 +157,7 @@ function renderLocationFilterChips() {
const rail = document.getElementById('selectedLocationFilters'); if (!rail) return;
rail.hidden = !state.locationFilters.length;
rail.innerHTML = state.locationFilters.map(filter => { const key = locationFilterKey(filter), label = locationFilterLabel(filter); return `<button class="selected-location-filter-chip" type="button" onclick="removeLocationFilter('${escapeAttribute(key)}')" aria-label="移除位置筛选 ${escapeAttribute(label)}"><span>${escapeHTML(label)}</span><b aria-hidden="true">×</b></button>`; }).join('');
const count = document.getElementById('regionPickerTriggerCount'); if (count) { count.hidden = !state.locationFilters.length; count.textContent = state.locationFilters.length; }
}
function removeLocationFilter(key) {
state.locationFilters = state.locationFilters.filter(filter => locationFilterKey(filter) !== key);
@@ -171,6 +169,55 @@ function addLocationFilter(option) {
if (!state.locationFilters.some(current => locationFilterKey(current) === locationFilterKey(filter))) state.locationFilters.push(filter);
renderLocationFilterChips();
}
function cloneLocationFilter(filter) { return { level: filter.level, province: filter.province || '', city: filter.city || '', district: filter.district || '', label: filter.label || '' }; }
function openRegionPicker() {
state.regionPicker = { level: 'province', province: '', city: '', search: '', draft: state.locationFilters.map(cloneLocationFilter), options: [] };
const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = false;
renderRegionPicker(); window.setTimeout(() => document.getElementById('regionPickerSearch')?.focus(), 0);
}
function closeRegionPicker() { state.regionPicker = null; const modal = document.getElementById('regionPickerModal'); if (modal) modal.hidden = true; }
function regionPickerOptions() {
const picker = state.regionPicker; if (!picker) return [];
const all = stationLocationOptions(); const needle = normalize(picker.search);
if (needle) return all.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, 80);
if (picker.level === 'province') return all.filter(option => option.level === 'province');
if (picker.level === 'city') return all.filter(option => option.level === 'city' && option.province === picker.province);
return all.filter(option => option.level === 'district' && option.province === picker.province && option.city === picker.city);
}
function regionPickerOptionPath(option) {
const parts = option.level === 'province' ? [option.province] : option.level === 'city' ? [option.province, option.city] : [option.province, option.city, option.district];
return `${compactPath(parts)} · ${formatNumber(option.stations.length)} 座站点`;
}
function regionPickerOptionSelected(option) { return state.regionPicker?.draft.some(filter => locationFilterKey(filter) === locationFilterKey(option)); }
function renderRegionPicker() {
const picker = state.regionPicker; if (!picker) return;
const search = document.getElementById('regionPickerSearch'); if (search && search.value !== picker.search) search.value = picker.search;
const clear = document.getElementById('regionPickerSearchClear'); if (clear) clear.hidden = !picker.search;
const selected = document.getElementById('regionPickerSelected');
if (selected) selected.innerHTML = picker.draft.length ? `<span class="region-picker-selected-label">已选</span>${picker.draft.map((filter, index) => `<button type="button" class="region-picker-selected-chip" onclick="removeRegionPickerDraft(${index})">${escapeHTML(locationFilterLabel(filter))}<b aria-hidden="true">×</b></button>`).join('')}` : '<span class="region-picker-selected-placeholder">可同时选择多个省 / 市 / 区县</span>';
const path = document.getElementById('regionPickerPath');
if (path) {
const crumbs = [{ label: '全部省份', level: 'province' }];
if (picker.province) crumbs.push({ label: stationGroupName({ province: picker.province }, 'province'), level: 'city' });
if (picker.city) crumbs.push({ label: stationGroupName({ city: picker.city }, 'city'), level: 'district' });
path.innerHTML = picker.search ? '<span>搜索结果</span>' : crumbs.map((crumb, index) => `<button type="button" class="${index === crumbs.length - 1 ? 'is-current' : ''}" onclick="navigateRegionPicker('${crumb.level}')">${escapeHTML(crumb.label)}</button>`).join('<i></i>');
}
const options = regionPickerOptions(); picker.options = options;
const list = document.getElementById('regionPickerList');
if (list) list.innerHTML = options.length ? options.map((option, index) => {
const selectedOption = regionPickerOptionSelected(option), canDescend = !picker.search && option.level !== 'district';
return `<article class="region-picker-row${selectedOption ? ' is-selected' : ''}"><button type="button" class="region-picker-check" onclick="toggleRegionPickerOption(${index})" aria-label="${selectedOption ? '取消选择' : '选择'} ${escapeAttribute(option.path)}"><span>${selectedOption ? '✓' : ''}</span></button><button type="button" class="region-picker-row-main" onclick="toggleRegionPickerOption(${index})"><strong>${escapeHTML(option.label)}</strong><small>${escapeHTML(regionPickerOptionPath(option))}</small></button>${canDescend ? `<button type="button" class="region-picker-next" onclick="descendRegionPicker(${index})" aria-label="浏览${escapeAttribute(option.label)}下级"></button>` : '<span class="region-picker-next is-empty"></span>'}</article>`;
}).join('') : '<div class="region-picker-empty">没有可选择的运营区域</div>';
const summary = document.getElementById('regionPickerSummary'); if (summary) summary.textContent = `已选 ${picker.draft.length}`;
const confirm = document.querySelector('.region-picker-confirm'); if (confirm) confirm.textContent = picker.draft.length ? `确认(${picker.draft.length}` : '确认';
}
function setRegionPickerSearch(value) { if (!state.regionPicker) return; state.regionPicker.search = value; renderRegionPicker(); }
function navigateRegionPicker(level) { if (!state.regionPicker) return; state.regionPicker.search = ''; if (level === 'province') { state.regionPicker.level = 'province'; state.regionPicker.province = ''; state.regionPicker.city = ''; } else if (level === 'city') { state.regionPicker.level = 'city'; state.regionPicker.city = ''; } else state.regionPicker.level = 'district'; renderRegionPicker(); }
function descendRegionPicker(index) { const option = state.regionPicker?.options[index]; if (!option || option.level === 'district') return; state.regionPicker.search = ''; state.regionPicker.level = option.level === 'province' ? 'city' : 'district'; state.regionPicker.province = option.province; state.regionPicker.city = option.city || ''; renderRegionPicker(); }
function toggleRegionPickerOption(index) { const option = state.regionPicker?.options[index]; if (!option) return; const key = locationFilterKey(option), draft = state.regionPicker.draft; const found = draft.findIndex(filter => locationFilterKey(filter) === key); if (found >= 0) draft.splice(found, 1); else draft.push(cloneLocationFilter(option)); renderRegionPicker(); }
function removeRegionPickerDraft(index) { if (!state.regionPicker) return; state.regionPicker.draft.splice(index, 1); renderRegionPicker(); }
function clearRegionPickerDraft() { if (!state.regionPicker) return; state.regionPicker.draft = []; renderRegionPicker(); }
function confirmRegionPicker() { if (!state.regionPicker) return; state.locationFilters = state.regionPicker.draft.map(cloneLocationFilter); state.query = ''; state.searchText = ''; const input = document.getElementById('stationSearch'); if (input) input.value = ''; const clear = document.getElementById('clearSearch'); if (clear) clear.hidden = true; state.selected = null; closeDetail(); closeStationSearchSuggestions(); renderLocationFilterChips(); closeRegionPicker(); render(); if (state.locationFilters.length) { focusSearchResults(); document.getElementById('selectedRegionHint').textContent = `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}`; } else resetMap(); }
function focusSearchResults() {
const stations = filteredStations(); if (!state.map || !stations.length) return;
const longitudes = stations.map(station => Number(station.longitude)), latitudes = stations.map(station => Number(station.latitude));
@@ -182,7 +229,6 @@ function focusSearchResults() {
function handleStationSearchInput(value) { state.searchText = value; document.getElementById('clearSearch').hidden = !normalize(value); state.activeSuggestion = -1; openStationSearchSuggestions(); window.clearTimeout(state.searchDebounce); state.searchDebounce = window.setTimeout(() => { state.query = value; state.selected = null; closeDetail(); render(); if (normalize(value)) focusSearchResults(); }, 180); }
function selectStationSearchSuggestion(index) {
const item = state.suggestions[index]; if (!item) return;
if (item.type === 'location') { addLocationFilter(item); state.query = ''; state.searchText = ''; document.getElementById('stationSearch').value = ''; document.getElementById('clearSearch').hidden = true; state.map?.setZoomAndCenter(item.level === 'province' ? 7.2 : item.level === 'city' ? 10.5 : 13, item.lnglat); document.getElementById('selectedRegionHint').textContent = `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}`; closeStationSearchSuggestions(); render(); return; }
closeStationSearchSuggestions(); selectStation(item.station, true);
}
function setStationFilter(filter) { if (!['all', 'partner'].includes(filter) || state.stationFilter === filter) return; state.stationFilter = filter; state.selected = null; closeDetail(); document.querySelectorAll('[data-station-filter]').forEach(button => button.classList.toggle('is-active', button.dataset.stationFilter === filter)); render(); renderStationSearchSuggestions(); }