1) refactor, 2) add dockerfile
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,3 +8,5 @@ wheels/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
*.tar
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@ -0,0 +1,51 @@
|
||||
# Modified by official dockerfile sample(https://github.com/astral-sh/uv-docker-example/blob/main/Dockerfile)
|
||||
# Use a Python image with uv pre-installed
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
|
||||
# Setup a non-root user
|
||||
RUN groupadd --system --gid 999 nonroot \
|
||||
&& useradd --system --gid 999 --uid 999 --create-home nonroot
|
||||
|
||||
# Install the project into `/app`
|
||||
WORKDIR /app
|
||||
|
||||
# Enable bytecode compilation
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Copy from the cache instead of linking since it's a mounted volume
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# Ensure installed tools can be executed out of the box
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
|
||||
# Install the project's dependencies using the lockfile and settings
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
|
||||
# Then, add the rest of the project source code and install it
|
||||
# Installing separately from its dependencies allows optimal layer caching
|
||||
COPY ./hiking_assistant /app
|
||||
COPY pyproject.toml /app
|
||||
COPY uv.lock /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# Place executables in the environment at the front of the path
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Reset the entrypoint, don't invoke `uv`
|
||||
ENTRYPOINT []
|
||||
|
||||
# Use the non-root user to run our application
|
||||
USER nonroot
|
||||
|
||||
# Run the Streamlit application by default
|
||||
ENV TZ="Asia/Taipei"
|
||||
EXPOSE 8504
|
||||
CMD ["python", "-m", "streamlit", "run", "app.py", "--server.port=8504", "--server.enableCORS=false", "--server.enableXsrfProtection=false"]
|
||||
|
||||
# Build
|
||||
# docker build --platform=linux/amd64 -t hiking_assistant:latest .
|
||||
# docker save -o hiking_assistant.tar hiking_assistant:latest
|
||||
@ -12,7 +12,8 @@
|
||||
## Installation
|
||||
```bash
|
||||
uv sync
|
||||
uv run streamlit run hiking_assistant/main.py
|
||||
cd hiking_assistant
|
||||
uv run streamlit run app.py
|
||||
```
|
||||
|
||||
## Dirs
|
||||
|
||||
200
hiking_assistant/app.py
Normal file
200
hiking_assistant/app.py
Normal file
@ -0,0 +1,200 @@
|
||||
# app.py
|
||||
#
|
||||
# author: deng
|
||||
# date: 20251127
|
||||
|
||||
import streamlit as st
|
||||
import yaml
|
||||
from elevation_profile import ElevationProfileRenderer
|
||||
from gpx import GPXProcessor
|
||||
from map_render import MapRenderer
|
||||
from streamlit_folium import st_folium
|
||||
from weather import WeatherFetcher
|
||||
|
||||
|
||||
class HikingAssistant:
|
||||
def __init__(self):
|
||||
self._config = self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
with open('assets/config.yaml', 'r') as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def run(self):
|
||||
"""Hiking Assistant application"""
|
||||
# Page configuration
|
||||
st.set_page_config(
|
||||
page_title=self._config['app']['page_title'],
|
||||
page_icon=self._config['app']['page_favicon_path'],
|
||||
layout='wide',
|
||||
initial_sidebar_state='collapsed',
|
||||
)
|
||||
|
||||
# Title and description
|
||||
st.title('⛰️ ' + self._config['app']['page_title'])
|
||||
|
||||
# File uploader
|
||||
uploaded_file = st.file_uploader(
|
||||
'上傳您的 GPX 檔案開始分析路線!',
|
||||
type=['gpx'],
|
||||
help='可以從[健行筆記](https://hiking.biji.co/index.php?q=trail&act=gpx_list)、'
|
||||
'[Hikingbook](https://zh-tw.hikingbook.net/explore/trails?regions=Taiwan)等網站下載 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()
|
||||
estimated_time = gpx_processor.calculate_naismith_time()
|
||||
|
||||
# Display statistics section
|
||||
st.header('📊 路線五四三')
|
||||
col1, col2, col3, col4, col5, col6 = st.columns(6)
|
||||
|
||||
with col1:
|
||||
st.metric(label='總距離', value=f'{total_distance:.1f}km')
|
||||
|
||||
with col2:
|
||||
st.metric(label='總爬升', value=f'{elevation_gain:.0f}m')
|
||||
|
||||
with col3:
|
||||
st.metric(label='總下降', value=f'{elevation_loss:.0f}m')
|
||||
|
||||
with col4:
|
||||
st.metric(label='最高海拔', value=f'{max_elevation:.0f}m')
|
||||
|
||||
with col5:
|
||||
st.metric(label='最低海拔', value=f'{min_elevation:.0f}m')
|
||||
|
||||
with col6:
|
||||
st.metric(
|
||||
label='預估行進時間',
|
||||
value=f'{estimated_time // 60}h {estimated_time % 60}m',
|
||||
help='依據[Naismith’s Rule](https://en.wikipedia.org/wiki/Naismith%27s_rule)計算',
|
||||
)
|
||||
|
||||
# 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, key='route_map', returned_objects=[])
|
||||
else:
|
||||
st.error('無法渲染地圖')
|
||||
|
||||
# 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:
|
||||
# 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, col5 = st.columns(5)
|
||||
|
||||
with col1:
|
||||
st.metric(label='🌡️ 溫度', value=weather_info['temperature'])
|
||||
|
||||
with col2:
|
||||
st.metric(label='💧 濕度', value=weather_info['humidity'])
|
||||
|
||||
with col3:
|
||||
# Get precipitation probability from weather_data
|
||||
precip_prob = weather_data.get('current_precipitation_prob')
|
||||
if precip_prob is not None:
|
||||
st.metric(label='☔ 降雨機率', value=f'{precip_prob}%')
|
||||
else:
|
||||
st.metric(label='☔ 降雨機率', value='N/A')
|
||||
|
||||
with col4:
|
||||
st.metric(label='🌧️ 降雨量', value=weather_info['precipitation'])
|
||||
|
||||
with col5:
|
||||
# Get wind direction and convert to flow direction
|
||||
wind_dir_degrees = weather_data.get('current_wind_direction')
|
||||
wind_dir_text = weather_fetcher.convert_wind_degrees_to_flow_direction(wind_dir_degrees)
|
||||
|
||||
# Get wind speed and level indicator
|
||||
wind_speed_value = weather_data.get('current_wind_speed')
|
||||
wind_level = weather_fetcher.get_wind_speed_indicator(wind_speed_value)
|
||||
|
||||
st.metric(
|
||||
label='💨 風速與流向',
|
||||
value=f'{weather_info["wind_speed"]} {wind_dir_text} {wind_level}',
|
||||
)
|
||||
|
||||
# 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)}')
|
||||
|
||||
st.divider()
|
||||
footer_text = f'<div style="text-align:center; font-size: 0.8em"><p>{self._config["app"]["page_footer_text"]}</p></div>'
|
||||
st.markdown(footer_text, unsafe_allow_html=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = HikingAssistant()
|
||||
app.run()
|
||||
4
hiking_assistant/assets/config.yaml
Normal file
4
hiking_assistant/assets/config.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
app:
|
||||
page_title: 臺灣登山小幫手
|
||||
page_favicon_path: ./assets/favicon_compressed.jpg
|
||||
page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估<br>Made with ❤️ by <a href="https://gitea.guineapig.love/deng">deng</a>
|
||||
@ -8,7 +8,44 @@ class ElevationProfileRenderer:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize elevation profile renderer."""
|
||||
pass
|
||||
self.color = {
|
||||
'steep_ascent': 'rgb(139, 69, 19)',
|
||||
'gentle_ascent': 'rgb(210, 180, 140)',
|
||||
'steep_descent': 'rgb(34, 139, 34)',
|
||||
'gentle_descent': 'rgb(154, 205, 50)',
|
||||
}
|
||||
|
||||
self.slope = {
|
||||
'steep_ascent': [15, float('inf')],
|
||||
'gentle_ascent': [0, 15],
|
||||
'steep_descent': [float('-inf'), -20],
|
||||
'gentle_descent': [-20, 0],
|
||||
}
|
||||
|
||||
self.label = {
|
||||
'steep_ascent': '陡上坡 (>= 15%)',
|
||||
'gentle_ascent': '緩上坡 (< 15%)',
|
||||
'steep_descent': '陡下坡 (<= -20%)',
|
||||
'gentle_descent': '緩下坡 (> -20%)',
|
||||
}
|
||||
|
||||
def _get_gradient_category(self, gradient):
|
||||
"""Determine gradient category based on slope thresholds.
|
||||
|
||||
Args:
|
||||
gradient: Gradient value in percentage
|
||||
|
||||
Returns:
|
||||
str: Category key ('steep_ascent', 'gentle_ascent', 'steep_descent', 'gentle_descent')
|
||||
"""
|
||||
if gradient >= self.slope['steep_ascent'][0]:
|
||||
return 'steep_ascent'
|
||||
elif gradient >= self.slope['gentle_ascent'][0]:
|
||||
return 'gentle_ascent'
|
||||
elif gradient <= self.slope['steep_descent'][1]:
|
||||
return 'steep_descent'
|
||||
else:
|
||||
return 'gentle_descent'
|
||||
|
||||
def _downsample_data(self, distances, elevations, gradients, max_points=800):
|
||||
"""Downsample data if there are too many points.
|
||||
@ -84,23 +121,10 @@ class ElevationProfileRenderer:
|
||||
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
|
||||
# Determine color based on gradient category
|
||||
category = self._get_gradient_category(gradient)
|
||||
color = self.color[category]
|
||||
is_ascending = gradient > 0
|
||||
|
||||
# Collect consecutive points with similar direction (ascending/descending)
|
||||
segment_x = [distances[i]]
|
||||
@ -179,14 +203,15 @@ class ElevationProfileRenderer:
|
||||
if i + 1 < len(distances):
|
||||
segment_distance = distances[i + 1] - distances[i]
|
||||
gradient = gradients[i]
|
||||
category = self._get_gradient_category(gradient)
|
||||
|
||||
if gradient >= 15:
|
||||
if category == 'steep_ascent':
|
||||
steep_ascent_km += segment_distance
|
||||
elif gradient > 0:
|
||||
elif category == 'gentle_ascent':
|
||||
gentle_ascent_km += segment_distance
|
||||
elif gradient <= -20:
|
||||
elif category == 'steep_descent':
|
||||
steep_descent_km += segment_distance
|
||||
else: # gradient < 0 and > -20
|
||||
else: # gentle_descent
|
||||
gentle_descent_km += segment_distance
|
||||
|
||||
return {
|
||||
@ -211,33 +236,23 @@ class ElevationProfileRenderer:
|
||||
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),
|
||||
labels=list(self.label.values()),
|
||||
values=list(stats.values()),
|
||||
marker=dict(colors=list(self.color.values())),
|
||||
textinfo='label+percent',
|
||||
textposition='inside',
|
||||
hovertemplate='%{label}<br>%{value:.2f} km<br>%{percent}<extra></extra>',
|
||||
hovertemplate='%{value:.2f} km<extra></extra>',
|
||||
showlegend=False, # Don't show legend as requested
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
title='坡度分布',
|
||||
title='',
|
||||
height=400,
|
||||
margin=dict(l=20, r=20, t=40, b=20),
|
||||
)
|
||||
@ -259,8 +274,8 @@ class ElevationProfileRenderer:
|
||||
x=[None],
|
||||
y=[None],
|
||||
mode='lines',
|
||||
line=dict(color='rgb(139,69,19)', width=3),
|
||||
name='陡上坡 (≥15%)',
|
||||
line=dict(color=self.color['steep_ascent'], width=3),
|
||||
name=self.label['steep_ascent'],
|
||||
)
|
||||
)
|
||||
|
||||
@ -269,8 +284,8 @@ class ElevationProfileRenderer:
|
||||
x=[None],
|
||||
y=[None],
|
||||
mode='lines',
|
||||
line=dict(color='rgb(210,180,140)', width=3),
|
||||
name='緩上坡 (<15%)',
|
||||
line=dict(color=self.color['gentle_ascent'], width=3),
|
||||
name=self.label['gentle_ascent'],
|
||||
)
|
||||
)
|
||||
|
||||
@ -279,8 +294,8 @@ class ElevationProfileRenderer:
|
||||
x=[None],
|
||||
y=[None],
|
||||
mode='lines',
|
||||
line=dict(color='rgb(34,139,34)', width=3),
|
||||
name='陡下坡 (≤-20%)',
|
||||
line=dict(color=self.color['steep_descent'], width=3),
|
||||
name=self.label['steep_descent'],
|
||||
)
|
||||
)
|
||||
|
||||
@ -289,8 +304,8 @@ class ElevationProfileRenderer:
|
||||
x=[None],
|
||||
y=[None],
|
||||
mode='lines',
|
||||
line=dict(color='rgb(154,205,50)', width=3),
|
||||
name='緩下坡 (>-20%)',
|
||||
line=dict(color=self.color['gentle_descent'], width=3),
|
||||
name=self.label['gentle_descent'],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""GPX file processing module for hiking route analysis."""
|
||||
# gpx.py
|
||||
#
|
||||
# author: deng
|
||||
# date: 20251127
|
||||
|
||||
import gpxpy
|
||||
import gpxpy.gpx
|
||||
@ -165,3 +168,31 @@ class GPXProcessor:
|
||||
gradients.append(gradient)
|
||||
|
||||
return gradients
|
||||
|
||||
def calculate_naismith_time(self):
|
||||
"""Calculate estimated hiking time using Naismith's Rule.
|
||||
|
||||
Naismith's Rule:
|
||||
- Base time: 1 hour per 5 km of horizontal distance
|
||||
- Add time: 1 hour per 600 meters of ascent
|
||||
|
||||
Returns:
|
||||
int: Estimated time in minutes
|
||||
"""
|
||||
if len(self.points) < 2:
|
||||
return 0
|
||||
|
||||
# Get total distance and elevation gain
|
||||
total_distance = self.calculate_distance() if not self.distances else self.distances[-1]
|
||||
elevation_gain, _ = self.calculate_elevation_gain_loss()
|
||||
|
||||
# Naismith's Rule calculation
|
||||
# 1 hour per 5 km = 0.2 hours per km
|
||||
time_for_distance = total_distance * 0.2
|
||||
|
||||
# 1 hour per 600 meters of ascent
|
||||
time_for_ascent = elevation_gain / 600.0
|
||||
|
||||
total_time = int((time_for_distance + time_for_ascent) * 60)
|
||||
|
||||
return total_time
|
||||
|
||||
@ -1,182 +0,0 @@
|
||||
"""台灣登山路線規劃輔助系統 - 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():
|
||||
"""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__':
|
||||
main()
|
||||
@ -1,4 +1,7 @@
|
||||
"""Map rendering module using Folium for interactive maps."""
|
||||
# map_render.py
|
||||
#
|
||||
# author: deng
|
||||
# date: 20251127
|
||||
|
||||
import folium
|
||||
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
"""Weather data fetching module using Open-Meteo API."""
|
||||
# weather.py
|
||||
#
|
||||
# author: deng
|
||||
# date: 20251127
|
||||
|
||||
import requests
|
||||
|
||||
@ -8,7 +11,44 @@ class WeatherFetcher:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize weather fetcher."""
|
||||
self.base_url = 'https://api.open-meteo.com/v1/forecast'
|
||||
self.api_url = 'https://api.open-meteo.com/v1/forecast'
|
||||
self.request_timeout = 8 # unit: second
|
||||
|
||||
def convert_wind_degrees_to_flow_direction(self, degrees):
|
||||
"""Convert wind direction from degrees to flow direction emoji.
|
||||
|
||||
Args:
|
||||
degrees: Wind direction in degrees (0-360)
|
||||
|
||||
Returns:
|
||||
str: Flow direction as emoji arrow (pointing where wind is blowing to)
|
||||
"""
|
||||
if degrees is None:
|
||||
return 'N/A'
|
||||
directions = ['⬇️', '↙️', '⬅️', '↖️', '⬆️', '↗️', '➡️', '↘️']
|
||||
index = round(degrees / 45) % 8
|
||||
return directions[index]
|
||||
|
||||
def get_wind_speed_indicator(self, wind_speed):
|
||||
"""Get wind speed level indicator based on speed.
|
||||
|
||||
Args:
|
||||
wind_speed: Wind speed in km/h
|
||||
|
||||
Returns:
|
||||
str: Emoji indicator (🟢/🟡/🟠/🔴)
|
||||
"""
|
||||
if wind_speed is None:
|
||||
return ''
|
||||
|
||||
if wind_speed < 20:
|
||||
return '🟢' # Safe
|
||||
elif wind_speed < 40:
|
||||
return '🟡' # Caution
|
||||
elif wind_speed < 60:
|
||||
return '🟠' # Alert
|
||||
else:
|
||||
return '🔴' # Dangerous
|
||||
|
||||
def get_weather(self, latitude, longitude):
|
||||
"""Fetch weather data for given coordinates.
|
||||
@ -25,18 +65,18 @@ class WeatherFetcher:
|
||||
params = {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m',
|
||||
'current': 'temperature_2m,relative_humidity_2m,precipitation,wind_speed_10m,wind_direction_10m',
|
||||
'daily': 'temperature_2m_max,temperature_2m_min,precipitation_probability_max',
|
||||
'timezone': 'Asia/Taipei',
|
||||
'timezone': 'auto',
|
||||
'forecast_days': 3,
|
||||
}
|
||||
|
||||
response = requests.get(self.base_url, params=params, timeout=10)
|
||||
response = requests.get(self.api_url, params=params, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Parse current weather
|
||||
# Parse weather data
|
||||
current = data.get('current', {})
|
||||
daily = data.get('daily', {})
|
||||
|
||||
@ -44,7 +84,9 @@ class WeatherFetcher:
|
||||
'current_temperature': current.get('temperature_2m'),
|
||||
'current_humidity': current.get('relative_humidity_2m'),
|
||||
'current_precipitation': current.get('precipitation'),
|
||||
'current_precipitation_prob': daily.get('precipitation_probability_max', [None])[0], # Today's rain probability
|
||||
'current_wind_speed': current.get('wind_speed_10m'),
|
||||
'current_wind_direction': current.get('wind_direction_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', []),
|
||||
|
||||
@ -13,6 +13,7 @@ dependencies = [
|
||||
"requests>=2.32.3",
|
||||
"geopy>=2.4.1",
|
||||
"streamlit-plotly-events>=0.0.6",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
<?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>
|
||||
2
uv.lock
generated
2
uv.lock
generated
@ -234,6 +234,7 @@ dependencies = [
|
||||
{ name = "geopy" },
|
||||
{ name = "gpxpy" },
|
||||
{ name = "plotly" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "streamlit" },
|
||||
{ name = "streamlit-folium" },
|
||||
@ -253,6 +254,7 @@ requires-dist = [
|
||||
{ name = "geopy", specifier = ">=2.4.1" },
|
||||
{ name = "gpxpy", specifier = ">=1.6.2" },
|
||||
{ name = "plotly", specifier = ">=5.24.1" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
{ name = "streamlit", specifier = ">=1.51.0" },
|
||||
{ name = "streamlit-folium", specifier = ">=0.23.1" },
|
||||
|
||||
Reference in New Issue
Block a user