diff --git a/.gitignore b/.gitignore
index 505a3b1..6ca5ec0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ wheels/
# Virtual environments
.venv
+
+*.tar
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fa58168
--- /dev/null
+++ b/Dockerfile
@@ -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
\ No newline at end of file
diff --git a/README.md b/README.md
index 29dbabb..7c66702 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/hiking_assistant/app.py b/hiking_assistant/app.py
new file mode 100644
index 0000000..d6f8ab1
--- /dev/null
+++ b/hiking_assistant/app.py
@@ -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'
{self._config["app"]["page_footer_text"]}
'
+ st.markdown(footer_text, unsafe_allow_html=True)
+
+
+if __name__ == '__main__':
+ app = HikingAssistant()
+ app.run()
diff --git a/hiking_assistant/assets/config.yaml b/hiking_assistant/assets/config.yaml
new file mode 100644
index 0000000..ada9889
--- /dev/null
+++ b/hiking_assistant/assets/config.yaml
@@ -0,0 +1,4 @@
+app:
+ page_title: 臺灣登山小幫手
+ page_favicon_path: ./assets/favicon_compressed.jpg
+ page_footer_text: ⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估
Made with ❤️ by deng
\ No newline at end of file
diff --git a/hiking_assistant/elevation_profile.py b/hiking_assistant/elevation_profile.py
index f145141..8b731a6 100644
--- a/hiking_assistant/elevation_profile.py
+++ b/hiking_assistant/elevation_profile.py
@@ -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}
%{value:.2f} km
%{percent}',
+ hovertemplate='%{value:.2f} km',
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'],
)
)
diff --git a/hiking_assistant/gpx.py b/hiking_assistant/gpx.py
index dda48c5..db6ec89 100644
--- a/hiking_assistant/gpx.py
+++ b/hiking_assistant/gpx.py
@@ -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
diff --git a/hiking_assistant/main.py b/hiking_assistant/main.py
deleted file mode 100644
index cc08ed4..0000000
--- a/hiking_assistant/main.py
+++ /dev/null
@@ -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()
diff --git a/hiking_assistant/map_render.py b/hiking_assistant/map_render.py
index b4ee5f4..223ae3c 100644
--- a/hiking_assistant/map_render.py
+++ b/hiking_assistant/map_render.py
@@ -1,4 +1,7 @@
-"""Map rendering module using Folium for interactive maps."""
+# map_render.py
+#
+# author: deng
+# date: 20251127
import folium
diff --git a/hiking_assistant/weather.py b/hiking_assistant/weather.py
index 63b9639..1bbda61 100644
--- a/hiking_assistant/weather.py
+++ b/hiking_assistant/weather.py
@@ -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', []),
diff --git a/pyproject.toml b/pyproject.toml
index 82c3713..e0c7878 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/test_hehuan.gpx b/test_hehuan.gpx
deleted file mode 100644
index b173861..0000000
--- a/test_hehuan.gpx
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
- 合歡山主峰測試路線
- 從合歡山主峰登山口到山頂的測試路線
-
-
- 合歡山主峰路線
-
-
- 3150
-
-
- 3180
-
-
- 3210
-
-
- 3250
-
-
- 3290
-
-
- 3320
-
-
- 3350
-
-
- 3360
-
-
- 3380
-
-
- 3400
-
-
- 3410
-
-
- 3416
-
-
- 3417
-
-
-
-
diff --git a/uv.lock b/uv.lock
index 7ee1a07..9e4c913 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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" },