Files
lingniu-vehicle-ingest/station-navigation/app.js
T

219 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const state = { stations: [], query: '', searchText: '', locationFilters: [], suggestions: [], activeSuggestion: -1, searchDebounce: null, stationFilter: 'all', selected: null, userLocation: null, map: null, markers: [], userMarker: null, pitch: true };
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.province, station.city, station.district, 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}`;
if (!groups.has(key)) groups.set(key, { type: 'location', level, province, city, district, 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;
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;
}
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('');
}
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 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;
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(); }
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[char])); }
function escapeAttribute(value) { return escapeHTML(value).replace(/`/g, '&#96;'); }
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]; }