poc
This commit is contained in:
11
README.md
11
README.md
@ -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**
|
||||||
|
|||||||
BIN
hiking_assistant/assets/favicon_compressed.jpg
Normal file
BIN
hiking_assistant/assets/favicon_compressed.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
174587
hiking_assistant/assets/南三段(童話世界).gpx
Normal file
174587
hiking_assistant/assets/南三段(童話世界).gpx
Normal file
File diff suppressed because it is too large
Load Diff
299
hiking_assistant/elevation_profile.py
Normal file
299
hiking_assistant/elevation_profile.py
Normal 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
167
hiking_assistant/gpx.py
Normal 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
|
||||||
@ -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()
|
||||||
|
|||||||
102
hiking_assistant/map_render.py
Normal file
102
hiking_assistant/map_render.py
Normal 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
|
||||||
99
hiking_assistant/weather.py
Normal file
99
hiking_assistant/weather.py
Normal 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
|
||||||
@ -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
54
test_hehuan.gpx
Normal 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
126
uv.lock
generated
@ -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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user