265 lines
34 KiB
JavaScript
265 lines
34 KiB
JavaScript
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(); });
|
||
|
||
async function loadStations() {
|
||
setStatus('正在同步加氢站数据…');
|
||
try {
|
||
const response = await fetch('/api/stations', { headers: { Accept: 'application/json' } });
|
||
const payload = await response.json();
|
||
if (!response.ok || payload.status !== 'ok') throw new Error(payload.message || `HTTP ${response.status}`);
|
||
state.stations = payload.stations.filter(hasCoordinates);
|
||
updateSummary(payload.summary);
|
||
setStatus(`站点数据已同步 · ${formatNumber(payload.summary.totalStations)} 座加氢站 · ${payload.asOf}`);
|
||
render();
|
||
} catch (error) {
|
||
console.error('station directory refresh failed', error);
|
||
setStatus('站点数据同步失败 · 将自动重试', true);
|
||
}
|
||
}
|
||
|
||
function initMap() {
|
||
if (typeof AMap === 'undefined') { setStatus('地图组件加载失败', true); return; }
|
||
state.map = new AMap.Map('amapContainer', { zoom: 4.8, center: [108.948024, 34.263161], viewMode: '3D', pitch: 30, mapStyle: 'amap://styles/light', showBuildingBlock: false, showLabel: true });
|
||
state.map.on('complete', render);
|
||
state.map.on('zoomend', render);
|
||
state.map.on('moveend', render);
|
||
}
|
||
|
||
function updateSummary(summary) {
|
||
const total = Number(summary.totalStations || 0), partner = Number(summary.cooperativeStations || 0), external = Math.max(0, total - partner);
|
||
setHTML('kpiTotal', `${formatNumber(total)} <small>座</small>`); setHTML('kpiPartner', `${formatNumber(partner)} <small>座</small>`);
|
||
setHTML('statusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('statusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('statusExternal', `${formatNumber(external)} <small>座</small>`);
|
||
setHTML('mobileStatusTotal', `${formatNumber(total)} <small>座</small>`); setHTML('mobileStatusPartner', `${formatNumber(partner)} <small>座</small>`); setHTML('mobileStatusExternal', `${formatNumber(external)} <small>座</small>`);
|
||
document.getElementById('partnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||
document.getElementById('externalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||
document.getElementById('mobilePartnerSegment').style.width = `${total ? partner * 100 / total : 0}%`;
|
||
document.getElementById('mobileExternalSegment').style.width = `${total ? external * 100 / total : 0}%`;
|
||
}
|
||
|
||
function stationMatchesQuery(station, query = state.query) { const needle = normalize(query); return !needle || [station.name, station.shortName, station.address].some(value => normalize(value).includes(needle)); }
|
||
function locationFilterKey(filter) { return [filter.level || 'location', filter.province || '', filter.city || '', filter.district || ''].join('|'); }
|
||
function stationMatchesLocationFilter(station, filter) { return (!filter.province || station.province === filter.province) && (!filter.city || station.city === filter.city) && (!filter.district || stationDistrictName(station) === filter.district); }
|
||
function stationMatchesLocation(station) { return !state.locationFilters.length || state.locationFilters.some(filter => stationMatchesLocationFilter(station, filter)); }
|
||
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.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;
|
||
}
|
||
function toPinyin(value) { const converter = globalThis.pinyinPro?.pinyin; return converter ? normalize(converter(String(value), { toneType: 'none', separator: ' ' })) : ''; }
|
||
function initials(value) { return String(value).split(/\s+/).map(word => word[0] || '').join(''); }
|
||
function normalize(value) { return String(value || '').trim().toLocaleLowerCase(); }
|
||
function hasCoordinates(station) { return Number.isFinite(Number(station.longitude)) && Number.isFinite(Number(station.latitude)); }
|
||
function coords(station) { return [Number(station.longitude), Number(station.latitude)]; }
|
||
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 render() { renderMarkers(); renderList(); }
|
||
function renderMarkers() {
|
||
if (!state.map || typeof AMap === 'undefined') return;
|
||
state.markers.forEach(marker => marker.remove()); state.markers = [];
|
||
const nodes = stationNodes(true);
|
||
nodes.forEach(node => {
|
||
const point = node.kind === 'station', content = document.createElement('div');
|
||
content.className = 'province-info-badge-wrap';
|
||
const label = point ? (node.cooperative ? 'H₂ · 合作' : '') : `${formatNumber(node.count)} 座`;
|
||
const subline = point ? node.adminPath : '';
|
||
content.innerHTML = `<div class="province-info-card${point ? ` is-station-point${node.cooperative ? '' : ' is-external'}${state.selected?.id === node.station.id ? ' is-selected' : ''}` : ''}"><div class="p-head"><span class="p-name">${escapeHTML(node.name)}</span>${label ? `<span class="p-total">${escapeHTML(label)}</span>` : ''}</div>${subline ? `<div class="p-sub">${escapeHTML(subline)}</div>` : ''}</div>`;
|
||
const marker = new AMap.Marker({ position: node.lnglat, content, offset: new AMap.Pixel(-30, -12), title: node.name, zIndex: point ? 20 : 10 });
|
||
marker.on('click', () => { if (point) selectStation(node.station, true); else state.map.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }); marker.setMap(state.map); state.markers.push(marker);
|
||
});
|
||
renderUserMarker();
|
||
}
|
||
function stationHierarchyLevel(zoom = state.map?.getZoom?.() || 0) {
|
||
if (normalize(state.query)) return 'station';
|
||
if (zoom < 7) return 'province';
|
||
if (zoom < 11) return 'city';
|
||
return 'station';
|
||
}
|
||
function stationGroupName(station, level) {
|
||
const raw = (level === 'province' ? station.province : station.city) || '未标注区域';
|
||
return level === 'province' ? String(raw).replace(/(壮族|回族|维吾尔)?自治区$|特别行政区$|省$|市$/, '') : String(raw).replace(/自治州$|地区$|林区$|[盟区县市]$/, '');
|
||
}
|
||
function buildStationNodes(stations = filteredStations(), level = stationHierarchyLevel()) {
|
||
if (level === 'station') return stations.map(station => ({ id: `station-${station.id}`, kind: 'station', level, name: station.name || station.shortName || '未命名站点', lnglat: coords(station), station, count: 1, online: station.cooperative ? 1 : 0, adminPath: stationRegion(station), detail: [stationRegion(station), station.address].filter(Boolean).join(' · '), cooperative: Boolean(station.cooperative) }));
|
||
const grouped = new Map();
|
||
for (const station of stations) {
|
||
const key = (level === 'province' ? station.province : `${station.province || ''}/${station.city || ''}`) || 'UNSPECIFIED';
|
||
const group = grouped.get(key) || { kind: level === 'province' ? 'stationProvince' : 'stationCity', level, name: stationGroupName(station, level), lng: 0, lat: 0, count: 0, online: 0, stations: [] };
|
||
group.lng += Number(station.longitude); group.lat += Number(station.latitude); group.count += 1; group.online += station.cooperative ? 1 : 0; group.stations.push(station); grouped.set(key, group);
|
||
}
|
||
return [...grouped.values()].map((group, index) => ({ ...group, id: `station-${level}-${index}`, lnglat: [group.lng / group.count, group.lat / group.count], detail: group.stations.slice(0, 5).map(station => station.name).join('、') }));
|
||
}
|
||
function currentMapBounds() { const bounds = state.map?.getBounds?.(), sw = bounds?.getSouthWest?.(), ne = bounds?.getNorthEast?.(); if (!sw || !ne) return null; return { west: sw.getLng(), south: sw.getLat(), east: ne.getLng(), north: ne.getLat() }; }
|
||
function nodesInsideCurrentMapBounds(nodes) { const bounds = currentMapBounds(); if (!bounds) return nodes; return nodes.filter(node => node.lnglat[0] >= bounds.west && node.lnglat[0] <= bounds.east && node.lnglat[1] >= bounds.south && node.lnglat[1] <= bounds.north); }
|
||
function stationNodes(visibleOnly = false) { const nodes = buildStationNodes(); return visibleOnly && !normalize(state.query) ? nodesInsideCurrentMapBounds(nodes) : nodes; }
|
||
function stationViewportSummary() { const level = stationHierarchyLevel(), allNodes = stationNodes(false), visibleNodes = normalize(state.query) ? allNodes : nodesInsideCurrentMapBounds(allNodes); return { level, allNodes, visibleNodes }; }
|
||
function nextZoomForLevel(level) { return level === 'province' ? 7.2 : level === 'city' ? 11.2 : 14; }
|
||
|
||
function renderList() {
|
||
const box = document.getElementById('stationList'); if (!box) return;
|
||
const { level, allNodes, visibleNodes } = stationViewportSummary();
|
||
const display = [...visibleNodes].sort(compareNodes);
|
||
document.getElementById('resultMeta').textContent = `视野 ${formatNumber(visibleNodes.reduce((sum, node) => sum + node.count, 0))} / ${formatNumber(filteredStations().length)}`;
|
||
document.getElementById('rankViewportMeta').textContent = `当前视野 ${formatNumber(visibleNodes.length)} · 全部 ${formatNumber(allNodes.length)}`;
|
||
document.getElementById('panelRankTitle').textContent = state.userLocation && level === 'station' ? `附近${state.stationFilter === 'partner' ? '合作' : ''}加氢站` : level === 'province' ? '加氢站省份 TOP 排名' : level === 'city' ? '加氢站城市 TOP 排名' : '加氢站 TOP 排名';
|
||
if (!display.length) { box.innerHTML = '<div class="rank-empty">未找到匹配站点<br>请尝试名称、地址、城市或拼音首字母</div>'; return; }
|
||
box.innerHTML = display.slice(0, 100).map((node, index) => { const distance = node.kind === 'station' ? stationDistance(node.station) : Number.POSITIVE_INFINITY, partnerTag = node.kind === 'station' ? `<span class="station-list-partner-tag${node.cooperative ? '' : ' is-placeholder'}"${node.cooperative ? '' : ' aria-hidden="true"'}>${node.cooperative ? '合作' : ''}</span>` : '', rowValue = Number.isFinite(distance) ? `${formatNumber(distance, 1)} km` : node.kind === 'station' ? '' : `${formatNumber(node.count)} 座`; return `<button type="button" class="rank-glass-row${node.kind === 'station' && state.selected?.id === node.station.id ? ' is-selected' : ''}" data-node-id="${escapeAttribute(node.id)}"><span class="r-badge ${index < 3 ? `r-top${index + 1}` : ''}">${index + 1}</span><span class="r-name">${partnerTag}<span class="station-list-name">${escapeHTML(node.name)}</span></span>${rowValue ? `<span class="r-val">${rowValue}</span>` : ''}</button>`; }).join('');
|
||
box.querySelectorAll('[data-node-id]').forEach(button => button.addEventListener('click', () => { const node = display.find(item => item.id === button.dataset.nodeId); if (node?.kind === 'station') selectStation(node.station, true); else if (node) state.map?.setZoomAndCenter(nextZoomForLevel(node.level), node.lnglat); }));
|
||
}
|
||
function compareNodes(left, right) { if (state.userLocation && left.kind === 'station' && right.kind === 'station') return stationDistance(left.station) - stationDistance(right.station); return right.count - left.count || String(left.name || '').localeCompare(String(right.name || ''), 'zh-CN'); }
|
||
|
||
function fuzzySearchScore(value, query) {
|
||
const needle = normalize(query); if (!needle) return 4;
|
||
const text = normalize(value); if (text === needle) return 0; if (text.startsWith(needle)) return 1; if (text.includes(needle)) return 2;
|
||
const pinyin = toPinyin(value); if (pinyin === needle || pinyin.startsWith(needle) || initials(pinyin).startsWith(needle)) return 3;
|
||
return pinyin.includes(needle) || initials(pinyin).includes(needle) ? 4 : Number.POSITIVE_INFINITY;
|
||
}
|
||
function compactPath(parts) { return parts.filter(Boolean).join(' · '); }
|
||
function stationLocationOptions() {
|
||
const groups = new Map();
|
||
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}`;
|
||
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 }); }
|
||
return [...groups.values()].map(option => {
|
||
const target = option.level === 'province' ? option.province : option.level === 'city' ? option.city : option.district;
|
||
const longitude = option.stations.reduce((sum, station) => sum + Number(station.longitude), 0) / option.stations.length;
|
||
const latitude = option.stations.reduce((sum, station) => sum + Number(station.latitude), 0) / option.stations.length;
|
||
return { ...option, label: stationGroupName({ province: target, city: target }, option.level === 'province' ? 'province' : 'city'), path: compactPath([option.province, option.city, option.district]), lnglat: [longitude, latitude] };
|
||
});
|
||
}
|
||
function stationLocationSuggestions(query) {
|
||
return stationLocationOptions().map(option => ({ ...option, score: Math.min(fuzzySearchScore(option.label, query), fuzzySearchScore(option.path, query)) }))
|
||
.filter(option => Number.isFinite(option.score)).sort((left, right) => left.score - right.score || left.label.localeCompare(right.label, 'zh-CN')).slice(0, normalize(query) ? 8 : 6);
|
||
}
|
||
function stationEntitySuggestions(query) {
|
||
if (!normalize(query)) return [];
|
||
return filteredStations().map(station => ({ type: 'station', station, score: stationSearchScore(station, query) }))
|
||
.filter(item => item.score > 0).sort((left, right) => right.score - left.score || String(left.station.name || '').localeCompare(String(right.station.name || ''), 'zh-CN')).slice(0, 5);
|
||
}
|
||
function renderStationSearchSuggestions() {
|
||
const box = document.getElementById('stationSearchSuggestions'); if (!box || box.hidden) return;
|
||
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; }
|
||
function locationFilterLabel(filter) { return filter.label || (filter.district ? stationGroupName({ city: filter.district }, 'city') : filter.city ? stationGroupName({ city: filter.city }, 'city') : stationGroupName({ province: filter.province }, 'province')); }
|
||
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);
|
||
state.selected = null; closeDetail(); renderLocationFilterChips(); render(); renderStationSearchSuggestions();
|
||
document.getElementById('selectedRegionHint').textContent = state.locationFilters.length ? `视角: 已筛选 ${state.locationFilters.map(locationFilterLabel).join('、')}` : '视角: 全国加氢站网络';
|
||
}
|
||
function addLocationFilter(option) {
|
||
const filter = { level: option.level, province: option.province, city: option.city, district: option.district, label: option.label };
|
||
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));
|
||
const center = [(Math.min(...longitudes) + Math.max(...longitudes)) / 2, (Math.min(...latitudes) + Math.max(...latitudes)) / 2];
|
||
const span = Math.max(Math.max(...longitudes) - Math.min(...longitudes), Math.max(...latitudes) - Math.min(...latitudes));
|
||
const zoom = stations.length === 1 ? 14 : span < .1 ? 13 : span < .35 ? 11.5 : span < 1 ? 9.5 : 7;
|
||
state.map.setZoomAndCenter(zoom, center);
|
||
}
|
||
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;
|
||
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(); }
|
||
function clearSearch() { window.clearTimeout(state.searchDebounce); document.getElementById('stationSearch').value = ''; state.query = ''; state.searchText = ''; state.locationFilters = []; renderLocationFilterChips(); closeStationSearchSuggestions(); closeDetail(); resetMap(); render(); }
|
||
function handleSearchKeydown(event) { if (event.key === 'Escape') { closeStationSearchSuggestions(); event.currentTarget.blur(); return; } if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return; const box = document.getElementById('stationSearchSuggestions'); if (box?.hidden) openStationSearchSuggestions(); if (event.key === 'Enter' && state.activeSuggestion >= 0) { event.preventDefault(); selectStationSearchSuggestion(state.activeSuggestion); return; } if (event.key !== 'Enter') { event.preventDefault(); const direction = event.key === 'ArrowDown' ? 1 : -1; state.activeSuggestion = (state.activeSuggestion + direction + state.suggestions.length) % Math.max(state.suggestions.length, 1); renderStationSearchSuggestions(); } }
|
||
|
||
function selectStation(station, fit = false) {
|
||
if (!station) return; state.selected = station; if (fit) state.map?.setZoomAndCenter(14.5, coords(station));
|
||
document.getElementById('detailName').textContent = station.name || station.shortName || '未命名站点'; document.getElementById('detailRegion').textContent = stationRegion(station);
|
||
const cooperative = Boolean(station.cooperative), tag = document.getElementById('detailType'); tag.textContent = cooperative ? '合作站' : '外部站'; tag.classList.toggle('is-station', cooperative);
|
||
const distance = stationDistance(station); document.getElementById('detailFields').innerHTML = `${detailField('站点状态', cooperative ? '合作站点' : '外部站点')}${detailField('行政区域', stationRegion(station))}${detailField('详细地址', station.address || '暂无详细地址', true)}${Number.isFinite(distance) ? detailField('距离我', `${formatNumber(distance, 1)} km`) : ''}`;
|
||
document.getElementById('stationDetail').hidden = false; renderMarkers(); renderList();
|
||
}
|
||
function detailField(label, value, full = false) { return `<div class="detail-field${full ? ' is-full' : ''}"><dt>${escapeHTML(label)}</dt><dd>${escapeHTML(value)}</dd></div>`; }
|
||
function stationRegion(station) { return [station.province, station.city, stationDistrictName(station)].filter(value => value && value !== '[]').filter((value, index, list) => index === 0 || value !== list[index - 1]).join(' · '); }
|
||
function closeDetail() { const detail = document.getElementById('stationDetail'); if (detail) detail.hidden = true; }
|
||
|
||
function locateMe() { if (!navigator.geolocation) return locationFailed('当前设备不支持定位'); const button = document.getElementById('locateDeviceBtn'); button.disabled = true; button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = '定位中'; navigator.geolocation.getCurrentPosition(position => { state.userLocation = wgs84ToGcj02(Number(position.coords.longitude), Number(position.coords.latitude)); button.disabled = false; button.classList.add('is-active'); document.getElementById('locateDeviceLabel').textContent = '已定位'; state.map?.setZoomAndCenter(13, state.userLocation); document.getElementById('selectedRegionHint').textContent = '视角: 我的位置附近'; render(); }, error => locationFailed(error?.code === 1 ? '请允许位置权限' : '定位失败'), { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 }); }
|
||
function locationFailed(label) { const button = document.getElementById('locateDeviceBtn'); button.disabled = false; button.classList.add('is-error'); document.getElementById('locateDeviceLabel').textContent = label; window.setTimeout(() => { button.classList.remove('is-error'); document.getElementById('locateDeviceLabel').textContent = state.userLocation ? '已定位' : '定位'; }, 2600); }
|
||
function renderUserMarker() { if (!state.map || !state.userLocation || typeof AMap === 'undefined') return; if (state.userMarker) state.userMarker.remove(); const content = document.createElement('div'); content.className = 'user-location-marker'; state.userMarker = new AMap.Marker({ position: state.userLocation, content, offset: new AMap.Pixel(-9, -9), zIndex: 50 }); state.userMarker.setMap(state.map); }
|
||
function stationDistance(station) { return state.userLocation ? distanceKm(state.userLocation, coords(station)) : Number.POSITIVE_INFINITY; }
|
||
function distanceKm(origin, target) { const radians = value => value * Math.PI / 180, dLat = radians(target[1] - origin[1]), dLng = radians(target[0] - origin[0]), a = Math.sin(dLat / 2) ** 2 + Math.cos(radians(origin[1])) * Math.cos(radians(target[1])) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); }
|
||
function resetMap() { state.map?.setZoomAndCenter(4.8, [108.948024, 34.263161]); state.map?.setPitch(30); document.getElementById('selectedRegionHint').textContent = '视角: 全国加氢站网络'; }
|
||
function toggleMapPitch() { state.pitch = !state.pitch; state.map?.setPitch(state.pitch ? 30 : 0); }
|
||
function setTheme(theme) { document.body.className = theme; document.querySelectorAll('.bw-btn').forEach(button => button.classList.remove('active')); document.querySelector(theme === 'theme-white' ? '.theme-white-btn' : '.theme-dark-btn')?.classList.add('active'); state.map?.setMapStyle(theme === 'theme-white' ? 'amap://styles/light' : 'amap://styles/darkblue'); }
|
||
function navigateToStation() { if (!state.selected) return; const [lng, lat] = coords(state.selected), name = encodeURIComponent(state.selected.name || state.selected.shortName || '加氢站'); const overlay = document.getElementById('navigationOverlay'); overlay.hidden = false; window.setTimeout(() => window.location.assign(`https://uri.amap.com/navigation?to=${lng.toFixed(6)},${lat.toFixed(6)},${name}&mode=car&coordinate=gaode&callnative=1`), 140); }
|
||
function setStatus(text, error = false) { const status = document.getElementById('mapStatusText'); if (status) status.textContent = text; status?.parentElement?.classList.toggle('is-error', error); }
|
||
function setHTML(id, value) { const element = document.getElementById(id); if (element) element.innerHTML = value; }
|
||
function formatNumber(value, digits = 0) { return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: digits, minimumFractionDigits: digits }); }
|
||
function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, char => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[char])); }
|
||
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '`'); }
|
||
function initClock() { const clock = document.getElementById('clockTime'), update = () => { if (clock) clock.textContent = new Date().toLocaleTimeString('zh-CN', { hour12: false }); }; update(); window.setInterval(update, 1000); }
|
||
function wgs84ToGcj02(lng, lat) { if (lng < 72.004 || lng > 137.8347 || lat < .8293 || lat > 55.8271) return [lng, lat]; const transformLat = (x, y) => -100 + 2*x + 3*y + .2*y*y + .1*x*y + .2*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(y*Math.PI)+40*Math.sin(y/3*Math.PI))*2/3 + (160*Math.sin(y/12*Math.PI)+320*Math.sin(y*Math.PI/30))*2/3; const transformLng = (x, y) => 300 + x + 2*y + .1*x*x + .1*x*y + .1*Math.sqrt(Math.abs(x)) + (20*Math.sin(6*x*Math.PI)+20*Math.sin(2*x*Math.PI))*2/3 + (20*Math.sin(x*Math.PI)+40*Math.sin(x/3*Math.PI))*2/3 + (150*Math.sin(x/12*Math.PI)+300*Math.sin(x/30*Math.PI))*2/3; const a = 6378245, ee = .00669342162296594323; let dLat = transformLat(lng-105, lat-35), dLng = transformLng(lng-105, lat-35); const radLat = lat / 180 * Math.PI, magic = 1 - ee * Math.sin(radLat) ** 2, sqrtMagic = Math.sqrt(magic); dLat = dLat * 180 / ((a * (1-ee)) / (magic * sqrtMagic) * Math.PI); dLng = dLng * 180 / (a / sqrtMagic * Math.cos(radLat) * Math.PI); return [lng + dLng, lat + dLat]; }
|