This commit is contained in:
deng
2025-11-27 14:19:09 +08:00
parent 469704bc19
commit 4feb38de71
11 changed files with 175630 additions and 6 deletions

View File

@ -1,11 +1,18 @@
## Abstract ## Abstract
登山路線規劃小幫手⛰️ 一個基於 Python + Streamlit 的網頁應用程式,讀取使用者上傳的 GPX 檔案並提供詳細的路線統計、互動式地圖、海拔分析圖及天氣預報等。
## Requirements ## Requirements
* Hardware * Hardware
* MacbookPro14 2021 * MacbookPro14 2021
* Software * Software
* MacOS 15.5 * Python 3.13+
* Docker 28.1.1
## Installation
```bash
uv sync
uv run streamlit run hiking_assistant/main.py
```
## Dirs ## Dirs
* **tests** * **tests**

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,299 @@
"""Elevation profile visualization module using Plotly."""
import plotly.graph_objects as go
class ElevationProfileRenderer:
"""Render elevation profiles with gradient-based coloring."""
def __init__(self):
"""Initialize elevation profile renderer."""
pass
def _downsample_data(self, distances, elevations, gradients, max_points=800):
"""Downsample data if there are too many points.
Args:
distances: List of distances
elevations: List of elevations
gradients: List of gradients
max_points: Maximum number of points to keep
Returns:
tuple: (downsampled_distances, downsampled_elevations, downsampled_gradients)
"""
if len(distances) <= max_points:
return distances, elevations, gradients
# Calculate step size for uniform downsampling
step = len(distances) / max_points
# Use floating point indexing for smoother sampling
indices = []
for i in range(max_points):
idx = int(i * step)
if idx not in indices and idx < len(distances):
indices.append(idx)
# Always include first and last points
if 0 not in indices:
indices.insert(0, 0)
if (len(distances) - 1) not in indices:
indices.append(len(distances) - 1)
downsampled_distances = [distances[i] for i in indices]
downsampled_elevations = [elevations[i] for i in indices]
# Recalculate gradients for downsampled data
downsampled_gradients = []
for i in range(1, len(indices)):
idx_prev = indices[i - 1]
idx_curr = indices[i]
if idx_prev < len(gradients) and idx_curr <= len(gradients):
# Use average gradient of the skipped segments
avg_gradient = sum(gradients[idx_prev:idx_curr]) / max(1, idx_curr - idx_prev)
downsampled_gradients.append(avg_gradient)
return downsampled_distances, downsampled_elevations, downsampled_gradients
def create_elevation_profile(self, distances, elevations, gradients):
"""Create an interactive elevation profile chart.
Args:
distances: List of cumulative distances in km
elevations: List of elevations in meters
gradients: List of gradients (slopes) in percentage
Returns:
plotly.graph_objects.Figure: Interactive elevation chart
"""
if not distances or not elevations or len(distances) != len(elevations):
return None
# Downsample if too many points
distances, elevations, gradients = self._downsample_data(distances, elevations, gradients)
fig = go.Figure()
# Add gradient list for first point (use 0 as placeholder)
gradients_extended = [0] + gradients
# Group consecutive segments with similar color into single traces
# This dramatically reduces the number of traces
i = 0
while i < len(distances) - 1:
gradient = gradients_extended[i + 1]
# Determine color based on gradient thresholds (earth-tone palette)
if gradient > 0: # Ascending
if gradient >= 15:
# Steep ascent: dark red-brown (brick/terracotta)
color = 'rgb(139, 69, 19)' # Saddle brown
else:
# Gentle ascent: light red-brown (sandy/tan)
color = 'rgb(210, 180, 140)' # Tan
is_ascending = True
else: # Descending
if gradient <= -20:
# Steep descent: dark green (forest green)
color = 'rgb(34, 139, 34)' # Forest green
else:
# Gentle descent: light green (sage/olive)
color = 'rgb(154, 205, 50)' # Yellow green
is_ascending = False
# Collect consecutive points with similar direction (ascending/descending)
segment_x = [distances[i]]
segment_y = [elevations[i]]
segment_gradients = []
j = i
while j < len(distances) - 1:
next_gradient = gradients_extended[j + 1]
# Check if same direction (both positive or both negative/zero)
if (next_gradient > 0) == is_ascending:
segment_x.append(distances[j + 1])
segment_y.append(elevations[j + 1])
segment_gradients.append(next_gradient)
j += 1
else:
break
# Add segment as a single trace with fill
avg_gradient = sum(segment_gradients) / len(segment_gradients) if segment_gradients else gradient
# Create fill color with 50% opacity (rgba format)
# Extract RGB values from the color string
rgb_values = color.replace('rgb(', '').replace(')', '').split(',')
r, g, b = int(rgb_values[0]), int(rgb_values[1]), int(rgb_values[2])
fill_color = f'rgba({r},{g},{b},0.5)'
fig.add_trace(
go.Scatter(
x=segment_x,
y=segment_y,
mode='lines',
line=dict(color=color, width=3),
fill='tozeroy', # Fill to y=0
fillcolor=fill_color, # Semi-transparent fill
hovertemplate=f'距離: %{{x:.2f}} km<br>海拔: %{{y:.0f}} m<br>平均坡度: {avg_gradient:.1f}%<extra></extra>',
showlegend=False,
)
)
i = j
# Update layout
fig.update_layout(
title='海拔剖面圖',
xaxis_title='距離 (公里)',
yaxis_title='海拔 (公尺)',
hovermode='closest',
template='plotly_white',
height=400,
xaxis=dict(showgrid=True, gridcolor='lightgray'),
yaxis=dict(showgrid=True, gridcolor='lightgray'),
)
return fig
def calculate_gradient_distribution(self, distances, gradients):
"""Calculate distance distribution across different gradient categories.
Args:
distances: List of cumulative distances in km
gradients: List of gradients in percentage
Returns:
dict: Dictionary with distances for each category
"""
if len(distances) < 2 or len(gradients) < 1:
return None
steep_ascent_km = 0.0 # >= 15%
gentle_ascent_km = 0.0 # 0% to 15%
steep_descent_km = 0.0 # <= -20%
gentle_descent_km = 0.0 # -20% to 0%
for i in range(len(gradients)):
if i + 1 < len(distances):
segment_distance = distances[i + 1] - distances[i]
gradient = gradients[i]
if gradient >= 15:
steep_ascent_km += segment_distance
elif gradient > 0:
gentle_ascent_km += segment_distance
elif gradient <= -20:
steep_descent_km += segment_distance
else: # gradient < 0 and > -20
gentle_descent_km += segment_distance
return {
'steep_ascent': steep_ascent_km,
'gentle_ascent': gentle_ascent_km,
'steep_descent': steep_descent_km,
'gentle_descent': gentle_descent_km,
}
def create_gradient_pie_chart(self, distances, gradients):
"""Create a pie chart showing gradient distribution.
Args:
distances: List of cumulative distances in km
gradients: List of gradients in percentage
Returns:
plotly.graph_objects.Figure: Pie chart figure
"""
stats = self.calculate_gradient_distribution(distances, gradients)
if not stats:
return None
# Prepare data
labels = ['陡上坡 (≥15%)', '緩上坡 (<15%)', '陡下坡 (≤-20%)', '緩下坡 (>-20%)']
values = [stats['steep_ascent'], stats['gentle_ascent'], stats['steep_descent'], stats['gentle_descent']]
colors = [
'rgb(139, 69, 19)', # Steep ascent - dark brown
'rgb(210, 180, 140)', # Gentle ascent - tan
'rgb(34, 139, 34)', # Steep descent - forest green
'rgb(154, 205, 50)', # Gentle descent - yellow green
]
# Create pie chart
fig = go.Figure(
data=[
go.Pie(
labels=labels,
values=values,
marker=dict(colors=colors),
textinfo='label+percent',
textposition='inside',
hovertemplate='%{label}<br>%{value:.2f} km<br>%{percent}<extra></extra>',
showlegend=False, # Don't show legend as requested
)
]
)
fig.update_layout(
title='坡度分布',
height=400,
margin=dict(l=20, r=20, t=40, b=20),
)
return fig
def add_gradient_legend(self, fig):
"""Add a custom legend explaining the color scheme.
Args:
fig: Plotly figure object
Returns:
plotly.graph_objects.Figure: Updated figure with legend
"""
# Add dummy traces for legend
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode='lines',
line=dict(color='rgb(139,69,19)', width=3),
name='陡上坡 (≥15%)',
)
)
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode='lines',
line=dict(color='rgb(210,180,140)', width=3),
name='緩上坡 (<15%)',
)
)
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode='lines',
line=dict(color='rgb(34,139,34)', width=3),
name='陡下坡 (≤-20%)',
)
)
fig.add_trace(
go.Scatter(
x=[None],
y=[None],
mode='lines',
line=dict(color='rgb(154,205,50)', width=3),
name='緩下坡 (>-20%)',
)
)
fig.update_layout(showlegend=True, legend=dict(x=0.02, y=0.98))
return fig

167
hiking_assistant/gpx.py Normal file
View File

@ -0,0 +1,167 @@
"""GPX file processing module for hiking route analysis."""
import gpxpy
import gpxpy.gpx
from geopy.distance import geodesic
class GPXProcessor:
"""Handle GPX file parsing and route analysis."""
def __init__(self, gpx_file):
"""Initialize GPX processor with uploaded file.
Args:
gpx_file: File-like object containing GPX data
"""
self.gpx_file = gpx_file
self.gpx = None
self.points = []
self.elevations = []
self.distances = [] # unit: km
def validate_and_parse(self):
"""Validate and parse the GPX file.
Returns:
bool: True if valid GPX file, False otherwise
Raises:
Exception: If file cannot be parsed
"""
try:
gpx_data = self.gpx_file.read()
if isinstance(gpx_data, bytes):
gpx_data = gpx_data.decode('utf-8')
self.gpx = gpxpy.parse(gpx_data)
# Extract points from all tracks and segments
for track in self.gpx.tracks:
for segment in track.segments:
for point in segment.points:
self.points.append((point.latitude, point.longitude))
self.elevations.append(point.elevation if point.elevation else 0)
return len(self.points) > 0
except Exception as e:
raise Exception(f'GPX 檔案解析失敗: {str(e)}')
def calculate_distance(self):
"""Calculate total distance of the route.
Returns:
float: Total distance in kilometers
"""
if len(self.points) < 2:
return 0.0
total_distance = 0.0
self.distances = [0.0] # Start at 0 km
for i in range(1, len(self.points)):
segment_distance = geodesic(self.points[i - 1], self.points[i]).kilometers
total_distance += segment_distance
self.distances.append(total_distance)
return total_distance
def calculate_elevation_gain_loss(self, threshold=3.0):
"""Calculate total elevation gain and loss with noise filtering.
GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
accumulate to create inflated elevation gain/loss values. This method uses
a threshold to filter out GPS noise.
Args:
threshold: Minimum elevation change in meters to be counted (default: 3.0)
This filters out GPS noise while preserving real elevation changes.
Returns:
tuple: (elevation_gain, elevation_loss) in meters
"""
if len(self.elevations) < 2:
return 0.0, 0.0
gain = 0.0
loss = 0.0
# Use cumulative approach to avoid missing real elevation changes
# Track cumulative change since last significant threshold crossing
cumulative_change = 0.0
for i in range(1, len(self.elevations)):
diff = self.elevations[i] - self.elevations[i - 1]
cumulative_change += diff
# Only count if cumulative change exceeds threshold
if cumulative_change >= threshold:
gain += cumulative_change
cumulative_change = 0.0
elif cumulative_change <= -threshold:
loss += abs(cumulative_change)
cumulative_change = 0.0
return gain, loss
def get_min_max_elevation(self):
"""Get minimum and maximum elevation.
Returns:
tuple: (min_elevation, max_elevation) in meters
"""
if not self.elevations:
return 0.0, 0.0
return min(self.elevations), max(self.elevations)
def get_start_end_points(self):
"""Get start and end point coordinates.
Returns:
tuple: ((start_lat, start_lon), (end_lat, end_lon))
"""
if len(self.points) < 2:
return None, None
return self.points[0], self.points[-1]
def get_all_points(self):
"""Get all route points.
Returns:
list: List of (latitude, longitude) tuples
"""
return self.points
def get_elevation_profile_data(self):
"""Get data for elevation profile visualization.
Returns:
tuple: (distances, elevations) where distances is cumulative km
"""
return self.distances, self.elevations
def get_gradients(self):
"""Calculate gradient (slope) for each segment.
Returns:
list: List of gradients in percentage
"""
if len(self.points) < 2:
return []
gradients = []
for i in range(1, len(self.points)):
distance_m = geodesic(self.points[i - 1], self.points[i]).meters
elevation_diff = self.elevations[i] - self.elevations[i - 1]
if distance_m > 0:
gradient = (elevation_diff / distance_m) * 100
else:
gradient = 0
gradients.append(gradient)
return gradients

View File

@ -1,6 +1,182 @@
"""台灣登山路線規劃輔助系統 - Streamlit Web Application"""
import streamlit as st
from elevation_profile import ElevationProfileRenderer
from gpx import GPXProcessor
from map_render import MapRenderer
from streamlit_folium import st_folium
from weather import WeatherFetcher
def main(): def main():
print("Hello from hiking-assistant!") """Main Streamlit application."""
# Page configuration
st.set_page_config(
page_title='臺灣登山路線分析小幫手',
page_icon='assets/favicon_compressed.jpg',
layout='wide',
initial_sidebar_state='collapsed',
)
# Title and description
st.title('⛰️ 臺灣登山路線分析小幫手')
st.markdown('上傳您的 GPX 檔案開始分析路線!')
# File uploader
uploaded_file = st.file_uploader(
'上傳檔案',
type=['gpx'],
help='請上傳包含登山路線的 GPX 檔案',
)
if uploaded_file is not None:
try:
# Process GPX file
with st.spinner('正在解析 GPX 檔案...'):
gpx_processor = GPXProcessor(uploaded_file)
if not gpx_processor.validate_and_parse():
st.error('❌ GPX 檔案格式錯誤或不包含有效的軌跡點')
return
# Calculate statistics
total_distance = gpx_processor.calculate_distance()
elevation_gain, elevation_loss = gpx_processor.calculate_elevation_gain_loss()
min_elevation, max_elevation = gpx_processor.get_min_max_elevation()
start_point, end_point = gpx_processor.get_start_end_points()
all_points = gpx_processor.get_all_points()
distances, elevations = gpx_processor.get_elevation_profile_data()
gradients = gpx_processor.get_gradients()
# Display statistics section
st.header('📊 路線統計資訊')
col1, col2, col3, col4, col5 = st.columns(5)
with col1:
st.metric(label='總距離', value=f'{total_distance:.2f}', delta='公里')
with col2:
st.metric(label='總爬升', value=f'{elevation_gain:.0f}', delta='公尺')
with col3:
st.metric(label='總下降', value=f'{elevation_loss:.0f}', delta='公尺')
with col4:
st.metric(label='最高海拔', value=f'{max_elevation:.0f}', delta='公尺')
with col5:
st.metric(label='最低海拔', value=f'{min_elevation:.0f}', delta='公尺')
st.divider()
# Map and Elevation profile section (no divider between them)
st.header('🗺️ 路線地圖')
# Map section
with st.spinner('正在渲染地圖...'):
map_renderer = MapRenderer()
route_map = map_renderer.create_route_map(all_points, start_point, end_point)
if route_map:
st_folium(route_map, width=None, height=500)
else:
st.error('無法渲染地圖')
# Elevation profile section (directly below map, no divider)
# st.markdown('🔴 **紅色**表示上升路段(顏色越深坡度越陡) | 🟢 **綠色**表示下降路段(顏色越深坡度越陡)')
# Create two columns: elevation profile on left, pie chart on right
profile_col, pie_col = st.columns([2, 1])
with profile_col:
with st.spinner('正在繪製海拔剖面圖...'):
profile_renderer = ElevationProfileRenderer()
elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients)
if elevation_fig:
# Add legend
elevation_fig = profile_renderer.add_gradient_legend(elevation_fig)
st.plotly_chart(elevation_fig, width='stretch')
else:
st.error('無法繪製海拔剖面圖')
with pie_col:
with st.spinner('正在分析坡度分布...'):
pie_fig = profile_renderer.create_gradient_pie_chart(distances, gradients)
if pie_fig:
st.plotly_chart(pie_fig, width='stretch')
else:
st.error('無法繪製坡度分布圖')
st.divider()
# Weather section
st.header('☀️ 起點天氣預報')
if start_point:
with st.spinner('正在獲取天氣資訊...'):
weather_fetcher = WeatherFetcher()
weather_data = weather_fetcher.get_weather(start_point[0], start_point[1])
if weather_data:
weather_info = weather_fetcher.format_weather_display(weather_data)
if weather_info:
# Current weather
st.subheader('當前天氣')
col1, col2, col3, col4 = st.columns(4)
with col1:
st.metric(label='🌡️ 溫度', value=weather_info['temperature'])
with col2:
st.metric(label='💧 濕度', value=weather_info['humidity'])
with col3:
st.metric(label='🌧️ 降雨量', value=weather_info['precipitation'])
with col4:
st.metric(label='💨 風速', value=weather_info['wind_speed'])
# Forecast
if weather_info['forecast']:
st.subheader('未來三天預報')
forecast_cols = st.columns(3)
for idx, forecast in enumerate(weather_info['forecast']):
with forecast_cols[idx]:
st.markdown(f'**{forecast["date"]}**')
st.write(f'🌡️ 高溫: {forecast["temp_max"]}°C')
st.write(f'🌡️ 低溫: {forecast["temp_min"]}°C')
st.write(f'🌧️ 降雨機率: {forecast["precip_prob"]}%')
else:
st.warning('⚠️ 無法格式化天氣資料')
else:
st.warning('⚠️ 無法獲取天氣資訊,請稍後再試')
else:
st.warning('⚠️ 無法確定起點位置')
except Exception as e:
st.error(f'❌ 處理檔案時發生錯誤: {str(e)}')
else:
# # Show instructions when no file is uploaded
# st.info('👆 請上傳 GPX 檔案開始分析')
# st.markdown(
# """
# ### 使用說明
# 1. 點擊上方的「選擇 GPX 檔案」按鈕
# 2. 選擇您的登山路線 GPX 檔案
# 3. 系統將自動分析並顯示:
# - 📊 路線統計資訊(距離、爬升、海拔等)
# - 🗺️ 互動式路線地圖
# - 📈 海拔剖面圖(顏色顯示坡度變化)
# - ☀️ 起點天氣預報
# """
# )
pass
if __name__ == "__main__": if __name__ == '__main__':
main() main()

View File

@ -0,0 +1,102 @@
"""Map rendering module using Folium for interactive maps."""
import folium
class MapRenderer:
"""Render hiking routes on interactive maps."""
def __init__(self):
"""Initialize map renderer."""
pass
def create_route_map(self, points, start_point, end_point):
"""Create an interactive map with the hiking route.
Args:
points: List of (latitude, longitude) tuples for the route
start_point: (latitude, longitude) tuple for start
end_point: (latitude, longitude) tuple for end
Returns:
folium.Map: Interactive map object
"""
if not points or len(points) < 2:
return None
# Calculate center of the route
center_lat = sum(p[0] for p in points) / len(points)
center_lon = sum(p[1] for p in points) / len(points)
# Create map centered on the route
route_map = folium.Map(
location=[center_lat, center_lon],
zoom_start=13,
tiles='OpenStreetMap',
)
# Add the route as a red polyline
folium.PolyLine(
locations=points,
color='red',
weight=4,
opacity=0.8,
tooltip='登山路線',
).add_to(route_map)
# Add start point marker (blue circle)
if start_point:
folium.CircleMarker(
location=start_point,
radius=8,
color='blue',
fill=True,
fill_color='blue',
fill_opacity=0.7,
popup='起點',
tooltip='起點',
).add_to(route_map)
# Add end point marker (blue circle)
if end_point:
folium.CircleMarker(
location=end_point,
radius=8,
color='blue',
fill=True,
fill_color='blue',
fill_opacity=0.7,
popup='終點',
tooltip='終點',
).add_to(route_map)
# Fit bounds to show entire route
route_map.fit_bounds(points)
return route_map
def add_hover_marker(self, route_map, position, label='當前位置'):
"""Add a hover position marker to the map.
Args:
route_map: Existing folium map
position: (latitude, longitude) tuple for the marker
label: Label for the marker
Returns:
folium.Map: Map with added marker
"""
if position and route_map:
folium.CircleMarker(
location=position,
radius=10,
color='orange',
fill=True,
fill_color='orange',
fill_opacity=0.9,
popup=label,
tooltip=label,
weight=3,
).add_to(route_map)
return route_map

View File

@ -0,0 +1,99 @@
"""Weather data fetching module using Open-Meteo API."""
import requests
class WeatherFetcher:
"""Fetch weather data for hiking locations."""
def __init__(self):
"""Initialize weather fetcher."""
self.base_url = 'https://api.open-meteo.com/v1/forecast'
def get_weather(self, latitude, longitude):
"""Fetch weather data for given coordinates.
Args:
latitude: Latitude of the location
longitude: Longitude of the location
Returns:
dict: Weather data with temperature, humidity, precipitation, etc.
Returns None if request fails.
"""
try:
params = {
'latitude': latitude,
'longitude': longitude,
'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m',
'daily': 'temperature_2m_max,temperature_2m_min,precipitation_probability_max',
'timezone': 'Asia/Taipei',
'forecast_days': 3,
}
response = requests.get(self.base_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Parse current weather
current = data.get('current', {})
daily = data.get('daily', {})
weather_info = {
'current_temperature': current.get('temperature_2m'),
'current_humidity': current.get('relative_humidity_2m'),
'current_precipitation': current.get('precipitation'),
'current_wind_speed': current.get('wind_speed_10m'),
'daily_temp_max': daily.get('temperature_2m_max', []),
'daily_temp_min': daily.get('temperature_2m_min', []),
'daily_precipitation_prob': daily.get('precipitation_probability_max', []),
'daily_time': daily.get('time', []),
}
return weather_info
except requests.exceptions.RequestException as e:
print(f'天氣資料獲取失敗: {str(e)}')
return None
except Exception as e:
print(f'天氣資料處理失敗: {str(e)}')
return None
def format_weather_display(self, weather_info):
"""Format weather data for display.
Args:
weather_info: Weather data dictionary from get_weather()
Returns:
dict: Formatted weather data for UI display
"""
if not weather_info:
return None
formatted = {
'temperature': f'{weather_info.get("current_temperature", "N/A")}°C',
'humidity': f'{weather_info.get("current_humidity", "N/A")}%',
'precipitation': f'{weather_info.get("current_precipitation", 0)} mm',
'wind_speed': f'{weather_info.get("current_wind_speed", "N/A")} km/h',
'forecast': [],
}
# Add 3-day forecast
times = weather_info.get('daily_time', [])
temp_max = weather_info.get('daily_temp_max', [])
temp_min = weather_info.get('daily_temp_min', [])
precip_prob = weather_info.get('daily_precipitation_prob', [])
for i in range(min(3, len(times))):
formatted['forecast'].append(
{
'date': times[i],
'temp_max': temp_max[i] if i < len(temp_max) else 'N/A',
'temp_min': temp_min[i] if i < len(temp_min) else 'N/A',
'precip_prob': precip_prob[i] if i < len(precip_prob) else 'N/A',
}
)
return formatted

View File

@ -4,7 +4,16 @@ version = "0.1.0"
description = "This is a web app to analyze gpx for hiking planning" description = "This is a web app to analyze gpx for hiking planning"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = ["streamlit>=1.51.0"] dependencies = [
"streamlit>=1.51.0",
"gpxpy>=1.6.2",
"folium>=0.18.0",
"streamlit-folium>=0.23.1",
"plotly>=5.24.1",
"requests>=2.32.3",
"geopy>=2.4.1",
"streamlit-plotly-events>=0.0.6",
]
[dependency-groups] [dependency-groups]
dev = ["pre-commit>=4.5.0", "pytest>=9.0.1", "ruff>=0.14.6"] dev = ["pre-commit>=4.5.0", "pytest>=9.0.1", "ruff>=0.14.6"]

54
test_hehuan.gpx Normal file
View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Hiking Assistant Test"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>合歡山主峰測試路線</name>
<desc>從合歡山主峰登山口到山頂的測試路線</desc>
</metadata>
<trk>
<name>合歡山主峰路線</name>
<trkseg>
<trkpt lat="24.1400" lon="121.2720">
<ele>3150</ele>
</trkpt>
<trkpt lat="24.1405" lon="121.2725">
<ele>3180</ele>
</trkpt>
<trkpt lat="24.1410" lon="121.2730">
<ele>3210</ele>
</trkpt>
<trkpt lat="24.1415" lon="121.2735">
<ele>3250</ele>
</trkpt>
<trkpt lat="24.1420" lon="121.2740">
<ele>3290</ele>
</trkpt>
<trkpt lat="24.1425" lon="121.2745">
<ele>3320</ele>
</trkpt>
<trkpt lat="24.1430" lon="121.2750">
<ele>3350</ele>
</trkpt>
<trkpt lat="24.1435" lon="121.2755">
<ele>3360</ele>
</trkpt>
<trkpt lat="24.1438" lon="121.2758">
<ele>3380</ele>
</trkpt>
<trkpt lat="24.1440" lon="121.2760">
<ele>3400</ele>
</trkpt>
<trkpt lat="24.1442" lon="121.2762">
<ele>3410</ele>
</trkpt>
<trkpt lat="24.1444" lon="121.2764">
<ele>3416</ele>
</trkpt>
<trkpt lat="24.1445" lon="121.2765">
<ele>3417</ele>
</trkpt>
</trkseg>
</trk>
</gpx>

126
uv.lock generated
View File

@ -36,6 +36,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
] ]
[[package]]
name = "branca"
version = "0.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" },
]
[[package]] [[package]]
name = "cachetools" name = "cachetools"
version = "6.2.2" version = "6.2.2"
@ -143,6 +155,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
] ]
[[package]]
name = "folium"
version = "0.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "branca" },
{ name = "jinja2" },
{ name = "numpy" },
{ name = "requests" },
{ name = "xyzservices" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/76/84a1b1b00ce71f9c0c44af7d80f310c02e2e583591fe7d4cb03baecd0d3f/folium-0.20.0.tar.gz", hash = "sha256:a0d78b9d5a36ba7589ca9aedbd433e84e9fcab79cd6ac213adbcff922e454cb9", size = 109932, upload-time = "2025-06-16T20:22:51.803Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/a8/5f764f333204db0390362a4356d03a43626997f26818a0e9396f1b3bd8c9/folium-0.20.0-py2.py3-none-any.whl", hash = "sha256:f0bc2a92acde20bca56367aa5c1c376c433f450608d058daebab2fc9bf8198bf", size = 113394, upload-time = "2025-06-16T20:22:50.318Z" },
]
[[package]]
name = "geographiclib"
version = "2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/78/4892343230a9d29faa1364564e525307a37e54ad776ea62c12129dbba704/geographiclib-2.1.tar.gz", hash = "sha256:6a6545e6262d0ed3522e13c515713718797e37ed8c672c31ad7b249f372ef108", size = 37004, upload-time = "2025-08-21T21:34:26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b3/802576f2ea5dcb48501bb162e4c7b7b3ca5654a42b2c968ef98a797a4c79/geographiclib-2.1-py3-none-any.whl", hash = "sha256:e2a873b9b9e7fc38721ad73d5f4e6c9ed140d428a339970f505c07056997d40b", size = 40740, upload-time = "2025-08-21T21:34:24.955Z" },
]
[[package]]
name = "geopy"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "geographiclib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/fd/ef6d53875ceab72c1fad22dbed5ec1ad04eb378c2251a6a8024bad890c3b/geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1", size = 117625, upload-time = "2023-11-23T21:49:32.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" },
]
[[package]] [[package]]
name = "gitdb" name = "gitdb"
version = "4.0.12" version = "4.0.12"
@ -167,12 +216,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" },
] ]
[[package]]
name = "gpxpy"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/ad/6f1a34e702c72cb495bb258396f237ded76c00f9fe67054a44d778d24ed9/gpxpy-1.6.2.tar.gz", hash = "sha256:a72c484b97ec42b80834353b029cc8ee1b79f0ffca1179b2210bb3baf26c01ae", size = 111695, upload-time = "2023-11-29T17:25:38.391Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/9f/62df6c1e52462bdd04275b36cec49efa9e8af7e7b834499eb288f73dcfbc/gpxpy-1.6.2-py3-none-any.whl", hash = "sha256:289bc2d80f116c988d0a1e763fda22838f83005573ece2bbc6521817b26fb40a", size = 42649, upload-time = "2023-11-29T17:25:35.76Z" },
]
[[package]] [[package]]
name = "hiking-assistant" name = "hiking-assistant"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "folium" },
{ name = "geopy" },
{ name = "gpxpy" },
{ name = "plotly" },
{ name = "requests" },
{ name = "streamlit" }, { name = "streamlit" },
{ name = "streamlit-folium" },
{ name = "streamlit-plotly-events" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -183,7 +248,16 @@ dev = [
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "streamlit", specifier = ">=1.51.0" }] requires-dist = [
{ name = "folium", specifier = ">=0.18.0" },
{ name = "geopy", specifier = ">=2.4.1" },
{ name = "gpxpy", specifier = ">=1.6.2" },
{ name = "plotly", specifier = ">=5.24.1" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "streamlit", specifier = ">=1.51.0" },
{ name = "streamlit-folium", specifier = ">=0.23.1" },
{ name = "streamlit-plotly-events", specifier = ">=0.0.6" },
]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@ -496,6 +570,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
] ]
[[package]]
name = "plotly"
version = "6.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/05/1199e2a03ce6637960bc1e951ca0f928209a48cfceb57355806a88f214cf/plotly-6.5.0.tar.gz", hash = "sha256:d5d38224883fd38c1409bef7d6a8dc32b74348d39313f3c52ca998b8e447f5c8", size = 7013624, upload-time = "2025-11-17T18:39:24.523Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c3/3031c931098de393393e1f93a38dc9ed6805d86bb801acc3cf2d5bd1e6b7/plotly-6.5.0-py3-none-any.whl", hash = "sha256:5ac851e100367735250206788a2b1325412aa4a4917a4fe3e6f0bc5aa6f3d90a", size = 9893174, upload-time = "2025-11-17T18:39:20.351Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -820,6 +907,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/60/868371b6482ccd9ef423c6f62650066cf8271fdb2ee84f192695ad6b7a96/streamlit-1.51.0-py3-none-any.whl", hash = "sha256:4008b029f71401ce54946bb09a6a3e36f4f7652cbb48db701224557738cfda38", size = 10171702, upload-time = "2025-10-29T17:07:35.97Z" }, { url = "https://files.pythonhosted.org/packages/39/60/868371b6482ccd9ef423c6f62650066cf8271fdb2ee84f192695ad6b7a96/streamlit-1.51.0-py3-none-any.whl", hash = "sha256:4008b029f71401ce54946bb09a6a3e36f4f7652cbb48db701224557738cfda38", size = 10171702, upload-time = "2025-10-29T17:07:35.97Z" },
] ]
[[package]]
name = "streamlit-folium"
version = "0.25.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "branca" },
{ name = "folium" },
{ name = "jinja2" },
{ name = "streamlit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/2d/99571b9ac124a4382db1c10d03fd95f5c126a3cbe7191789b5b807890d2c/streamlit_folium-0.25.3.tar.gz", hash = "sha256:5ec11b3eff85ec0d6259e72e5597bd79ca7ad65b8837222220964b78428f415b", size = 522601, upload-time = "2025-09-30T22:45:36.325Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/35/d3cdab8cff94971714f866181abb1aa84ad976f6e7b6218a0499197465e4/streamlit_folium-0.25.3-py3-none-any.whl", hash = "sha256:cfdf085764da3f9b5e1e0668f6e4cc0385ff041c98133d023800983a875ca26c", size = 524601, upload-time = "2025-09-30T22:45:34.825Z" },
]
[[package]]
name = "streamlit-plotly-events"
version = "0.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "plotly" },
{ name = "streamlit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/8c/e51c4867cc4d819f8452811199241d0edbcec73621768270ada66153f874/streamlit-plotly-events-0.0.6.tar.gz", hash = "sha256:1fe25dbf0e5d803aeb90253be04d7b395f5bcfdf3c654f96ff3c19424e7f9582", size = 4874598, upload-time = "2021-04-26T23:36:16.973Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/08/51dd5c822e27410f6452c3cfe61f7163fdeb40f4d285f2da6c2b9f88584d/streamlit_plotly_events-0.0.6-py3-none-any.whl", hash = "sha256:e63fbe3c6a0746fdfce20060fc45ba5cd97805505c332b27372dcbd02c2ede29", size = 14802161, upload-time = "2021-04-26T23:36:09.194Z" },
]
[[package]] [[package]]
name = "tenacity" name = "tenacity"
version = "9.1.2" version = "9.1.2"
@ -915,3 +1030,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
] ]
[[package]]
name = "xyzservices"
version = "2025.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" },
]