dev #1

Merged
deng merged 2 commits from dev into main 2026-05-25 05:54:54 +00:00
11 changed files with 125 additions and 51 deletions

View File

@ -1,5 +1,14 @@
# 更新紀錄 # 更新紀錄
## [1.0.2] - 2026-05-25
### ⚡ 效能優化
- 將設定檔格式從 YAML 改為 TOML改用 Python 內建 `tomllib` 解析,減少第三方依賴並加快啟動速度
- 在背景執行緒預熱重量級模組(`plotly``folium``gpxpy``numpy``geopy`),縮短首次載入頁面時間
---
## [1.0.0] - 2025-12-03 ## [1.0.0] - 2025-12-03
### 🎉 首次正式發布 ### 🎉 首次正式發布

View File

@ -47,5 +47,5 @@ EXPOSE 8800
CMD ["python", "-m", "streamlit", "run", "app.py"] CMD ["python", "-m", "streamlit", "run", "app.py"]
# Build # Build
# docker build --platform=linux/amd64 -t hiking_assistant:1.0.0 . # docker build --platform=linux/amd64 -t hiking_assistant:1.0.2 .
# docker save -o hiking_assistant.tar hiking_assistant:1.0.0 # docker save -o hiking_assistant.tar hiking_assistant:1.0.2

View File

@ -3,27 +3,26 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
import threading
from datetime import datetime from datetime import datetime
import streamlit as st import streamlit as st
import yaml from utils import convert_image_to_base64, parse_config
from elevation import ElevationRenderer
from gpx import GPXProcessor
from map import MapRenderer def _prewarm_imports() -> None:
from streamlit_folium import st_folium """Pre-import heavy modules in background."""
from utils import convert_image_to_base64 import elevation # noqa: F401 (imports plotly)
from weather import WeatherFetcher import gpx # noqa: F401 (imports gpxpy, numpy, geopy)
import map # noqa: F401 (imports folium)
import weather # noqa: F401
class HikingAssistant: class HikingAssistant:
def __init__(self): def __init__(self, config_path: str = 'assets/config.toml') -> None:
self._config = self._load_config() self._config = parse_config(config_path)
def _load_config(self): def _set_page_background(self, image_path: str, opacity: float = 0.8) -> None:
with open('assets/config.yaml', 'r') as f:
return yaml.safe_load(f)
def _set_page_background(self, image_path, opacity=0.8):
"""Set background image for the application. """Set background image for the application.
Args: Args:
@ -46,7 +45,7 @@ class HikingAssistant:
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def _add_custom_title(self, title, image_path): def _add_custom_title(self, title: str, image_path: str) -> None:
favicon_data = convert_image_to_base64(image_path) favicon_data = convert_image_to_base64(image_path)
ext = image_path.split('.')[-1] ext = image_path.split('.')[-1]
st.markdown( st.markdown(
@ -59,7 +58,7 @@ class HikingAssistant:
unsafe_allow_html=True, unsafe_allow_html=True,
) )
def run(self): def run(self) -> None:
"""Hiking Assistant application""" """Hiking Assistant application"""
# Page configuration # Page configuration
st.set_page_config( st.set_page_config(
@ -93,6 +92,8 @@ class HikingAssistant:
if 'gpx_file_key' not in st.session_state or st.session_state.gpx_file_key != file_key: if 'gpx_file_key' not in st.session_state or st.session_state.gpx_file_key != file_key:
# Process GPX file (only when it's a new file) # Process GPX file (only when it's a new file)
with st.spinner('正在解析 GPX 檔案...'): with st.spinner('正在解析 GPX 檔案...'):
from gpx import GPXProcessor
gpx_processor = GPXProcessor(uploaded_file) gpx_processor = GPXProcessor(uploaded_file)
if not gpx_processor.validate_and_parse(): if not gpx_processor.validate_and_parse():
@ -176,6 +177,9 @@ class HikingAssistant:
# Map section # Map section
with st.spinner('正在渲染地圖...'): with st.spinner('正在渲染地圖...'):
from map import MapRenderer
from streamlit_folium import st_folium
map_renderer = MapRenderer() map_renderer = MapRenderer()
route_map = map_renderer.create_route_map(all_points, start_point, end_point, waypoints) route_map = map_renderer.create_route_map(all_points, start_point, end_point, waypoints)
@ -189,6 +193,8 @@ class HikingAssistant:
with profile_col: with profile_col:
with st.spinner('正在繪製海拔剖面圖...'): with st.spinner('正在繪製海拔剖面圖...'):
from elevation import ElevationRenderer
profile_renderer = ElevationRenderer() profile_renderer = ElevationRenderer()
elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients) elevation_fig = profile_renderer.create_elevation_profile(distances, elevations, gradients)
@ -213,6 +219,8 @@ class HikingAssistant:
st.header('☀️ 天氣安抓') st.header('☀️ 天氣安抓')
if start_point: if start_point:
from weather import WeatherFetcher
weather_fetcher = WeatherFetcher(forecast_days=self._config['app']['weather']['forecast_days']) weather_fetcher = WeatherFetcher(forecast_days=self._config['app']['weather']['forecast_days'])
weather_key = f'weather_{start_point[0]:.6f}_{start_point[1]:.6f}' weather_key = f'weather_{start_point[0]:.6f}_{start_point[1]:.6f}'
@ -343,6 +351,12 @@ class HikingAssistant:
st.markdown(footer_text, unsafe_allow_html=True) st.markdown(footer_text, unsafe_allow_html=True)
@st.cache_resource(show_spinner=False, max_entries=1)
def get_app_instance() -> HikingAssistant:
return HikingAssistant()
if __name__ == '__main__': if __name__ == '__main__':
app = HikingAssistant() threading.Thread(target=_prewarm_imports, daemon=True).start()
app = get_app_instance()
app.run() app.run()

View File

@ -0,0 +1,22 @@
[app]
version = "1.0.2"
page_title = "山山登山小助手"
page_favicon_path = "./assets/new_favicon.webp"
page_footer_text = "⚠️ 本服務提供之資訊僅供規劃參考,山區氣候瞬息萬變,請務必依據現場狀況與自身能力進行風險評估<br>Made with ❤️ by <a href=\"https://gitea.guineapig.love/deng\">deng</a>"
page_background_path = "./assets/background_compressed.jpg"
page_background_opacity = 0.8
[app.altitude_sickness]
elevation_threshold = 2100
warning_text = "此路線海拔較高,請留意[高山症](https://www.ysnp.gov.tw/StaticPage/MountainSickness)發生風險"
emoji = "💊"
[app.estimated_time]
horizontal_speed = 3
vertical_speed = 400
[app.map]
height = 550
[app.weather]
forecast_days = 7

View File

@ -3,13 +3,15 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import plotly.graph_objects as go import plotly.graph_objects as go
class ElevationRenderer: class ElevationRenderer:
"""Render elevation profiles with gradient-based coloring.""" """Render elevation profiles with gradient-based coloring."""
def __init__(self): def __init__(self) -> None:
"""Initialize elevation profile renderer.""" """Initialize elevation profile renderer."""
self.color = { self.color = {
'steep_ascent': 'rgb(139, 69, 19)', 'steep_ascent': 'rgb(139, 69, 19)',
@ -32,7 +34,7 @@ class ElevationRenderer:
'gentle_descent': '緩下坡 (> -20%)', 'gentle_descent': '緩下坡 (> -20%)',
} }
def _get_gradient_category(self, gradient): def _get_gradient_category(self, gradient: float) -> str:
"""Determine gradient category based on slope thresholds. """Determine gradient category based on slope thresholds.
Args: Args:
@ -50,7 +52,9 @@ class ElevationRenderer:
else: else:
return 'gentle_descent' return 'gentle_descent'
def _downsample_data(self, distances, elevations, gradients, max_points=800): def _downsample_data(
self, distances: list[float], elevations: list[float], gradients: list[float], max_points: int = 800
) -> tuple[list[float], list[float], list[float]]:
"""Downsample data if there are too many points. """Downsample data if there are too many points.
Args: Args:
@ -96,7 +100,7 @@ class ElevationRenderer:
return downsampled_distances, downsampled_elevations, downsampled_gradients return downsampled_distances, downsampled_elevations, downsampled_gradients
def create_elevation_profile(self, distances, elevations, gradients): def create_elevation_profile(self, distances: list[float], elevations: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create an interactive elevation profile chart. """Create an interactive elevation profile chart.
Args: Args:
@ -184,7 +188,7 @@ class ElevationRenderer:
return fig return fig
def calculate_gradient_distribution(self, distances, gradients): def calculate_gradient_distribution(self, distances: list[float], gradients: list[float]) -> Optional[dict[str, float]]:
"""Calculate distance distribution across different gradient categories. """Calculate distance distribution across different gradient categories.
Args: Args:
@ -224,7 +228,7 @@ class ElevationRenderer:
'gentle_descent': gentle_descent_km, 'gentle_descent': gentle_descent_km,
} }
def create_gradient_pie_chart(self, distances, gradients): def create_gradient_pie_chart(self, distances: list[float], gradients: list[float]) -> Optional[go.Figure]:
"""Create a pie chart showing gradient distribution. """Create a pie chart showing gradient distribution.
Args: Args:
@ -262,7 +266,7 @@ class ElevationRenderer:
return fig return fig
def add_gradient_legend(self, fig): def add_gradient_legend(self, fig: go.Figure) -> go.Figure:
"""Add a custom legend explaining the color scheme. """Add a custom legend explaining the color scheme.
Args: Args:

View File

@ -3,6 +3,8 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import IO, Optional
import gpxpy import gpxpy
import numpy as np import numpy as np
from geopy.distance import geodesic from geopy.distance import geodesic
@ -11,7 +13,7 @@ from geopy.distance import geodesic
class GPXProcessor: class GPXProcessor:
"""Handle GPX file parsing and route analysis.""" """Handle GPX file parsing and route analysis."""
def __init__(self, gpx_file): def __init__(self, gpx_file: IO) -> None:
"""Initialize GPX processor with uploaded file. """Initialize GPX processor with uploaded file.
Args: Args:
@ -25,7 +27,7 @@ class GPXProcessor:
self.distances = [] # unit: km self.distances = [] # unit: km
self.waypoints = [] # list of (lat, lon, name, elevation) self.waypoints = [] # list of (lat, lon, name, elevation)
def _get_sample_rate(self): def _get_sample_rate(self) -> float:
"""Get the median time interval between points. """Get the median time interval between points.
Returns: Returns:
@ -37,7 +39,7 @@ class GPXProcessor:
median_sample_time = np.median(diff).total_seconds() median_sample_time = np.median(diff).total_seconds()
return median_sample_time return median_sample_time
def validate_and_parse(self): def validate_and_parse(self) -> bool:
"""Validate and parse the GPX file. """Validate and parse the GPX file.
Returns: Returns:
@ -77,7 +79,7 @@ class GPXProcessor:
except Exception as e: except Exception as e:
raise Exception(f'GPX 檔案解析失敗: {str(e)}') raise Exception(f'GPX 檔案解析失敗: {str(e)}')
def calculate_distance(self): def calculate_distance(self) -> float:
"""Calculate total distance of the route. """Calculate total distance of the route.
Returns: Returns:
@ -96,7 +98,7 @@ class GPXProcessor:
return total_distance return total_distance
def calculate_elevation_gain_loss(self, threshold=1.0): def calculate_elevation_gain_loss(self, threshold: float = 1.0) -> tuple[float, float]:
"""Calculate total elevation gain and loss with noise filtering. """Calculate total elevation gain and loss with noise filtering.
GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can GPS altitude data is notoriously inaccurate. Small fluctuations (noise) can
@ -127,7 +129,7 @@ class GPXProcessor:
return gain, loss return gain, loss
def get_min_max_elevation(self): def get_min_max_elevation(self) -> tuple[float, float]:
"""Get minimum and maximum elevation. """Get minimum and maximum elevation.
Returns: Returns:
@ -138,7 +140,7 @@ class GPXProcessor:
return min(self.elevations), max(self.elevations) return min(self.elevations), max(self.elevations)
def get_start_end_points(self): def get_start_end_points(self) -> tuple[Optional[tuple[float, float, float]], Optional[tuple[float, float, float]]]:
"""Get start and end point coordinates with elevation. """Get start and end point coordinates with elevation.
Returns: Returns:
@ -149,7 +151,7 @@ class GPXProcessor:
return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1]) return (self.points[0][0], self.points[0][1], self.elevations[0]), (self.points[-1][0], self.points[-1][1], self.elevations[-1])
def get_all_points(self): def get_all_points(self) -> list[tuple[float, float]]:
"""Get all route points. """Get all route points.
Returns: Returns:
@ -157,7 +159,7 @@ class GPXProcessor:
""" """
return self.points return self.points
def get_elevation_profile_data(self): def get_elevation_profile_data(self) -> tuple[list[float], list[float]]:
"""Get data for elevation profile visualization. """Get data for elevation profile visualization.
Returns: Returns:
@ -165,7 +167,7 @@ class GPXProcessor:
""" """
return self.distances, self.elevations return self.distances, self.elevations
def get_gradients(self): def get_gradients(self) -> list[float]:
"""Calculate gradient (slope) for each segment. """Calculate gradient (slope) for each segment.
Returns: Returns:
@ -188,7 +190,7 @@ class GPXProcessor:
return gradients return gradients
def calculate_naismith_time(self, horizontal_speed=5, vertical_speed=600): def calculate_naismith_time(self, horizontal_speed: float = 5, vertical_speed: float = 600) -> int:
"""Calculate estimated hiking time using Naismith's Rule. """Calculate estimated hiking time using Naismith's Rule.
Naismith's Rule: Naismith's Rule:

View File

@ -3,17 +3,26 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import folium import folium
class MapRenderer: class MapRenderer:
"""Render hiking routes on interactive maps.""" """Render hiking routes on interactive maps."""
def __init__(self): def __init__(self) -> None:
"""Initialize map renderer.""" """Initialize map renderer."""
pass pass
def create_route_map(self, points, start_point, end_point, waypoints=None, tile_layer='OpenStreetMap'): def create_route_map(
self,
points: list[tuple[float, float]],
start_point: Optional[tuple[float, float, float]],
end_point: Optional[tuple[float, float, float]],
waypoints: Optional[list[dict]] = None,
tile_layer: str = 'OpenStreetMap',
) -> Optional[folium.Map]:
"""Create an interactive map with the hiking route. """Create an interactive map with the hiking route.
Args: Args:
@ -117,7 +126,7 @@ class MapRenderer:
return route_map return route_map
def add_hover_marker(self, route_map, position, label='當前位置'): def add_hover_marker(self, route_map: folium.Map, position: tuple[float, float], label: str = '當前位置') -> folium.Map:
"""Add a hover position marker to the map. """Add a hover position marker to the map.
Args: Args:

View File

@ -4,11 +4,26 @@
# date: 20251128 # date: 20251128
import base64 import base64
import tomllib
import streamlit as st import streamlit as st
@st.cache_data @st.cache_data
def convert_image_to_base64(image_path): def convert_image_to_base64(image_path: str) -> str:
with open(image_path, 'rb') as f: with open(image_path, 'rb') as f:
return base64.b64encode(f.read()).decode() return base64.b64encode(f.read()).decode()
def parse_config(config_path: str) -> dict:
"""Config parser
Args:
config_path (str): path of config toml.
Returns:
dict: configuration dictionary
"""
with open(config_path, 'rb') as file:
config = tomllib.load(file)
return config

View File

@ -3,19 +3,21 @@
# author: deng # author: deng
# date: 20251127 # date: 20251127
from typing import Optional
import requests import requests
class WeatherFetcher: class WeatherFetcher:
"""Fetch weather data for hiking locations.""" """Fetch weather data for hiking locations."""
def __init__(self, api_url='https://api.open-meteo.com/v1/forecast', request_timeout=8, forecast_days=7): def __init__(self, api_url: str = 'https://api.open-meteo.com/v1/forecast', request_timeout: int = 8, forecast_days: int = 7) -> None:
"""Initialize weather fetcher.""" """Initialize weather fetcher."""
self.api_url = api_url self.api_url = api_url
self.request_timeout = request_timeout self.request_timeout = request_timeout
self.forecast_days = max(forecast_days, 1) self.forecast_days = max(forecast_days, 1)
def convert_wind_degrees_to_flow_direction(self, degrees): def convert_wind_degrees_to_flow_direction(self, degrees: Optional[float]) -> str:
"""Convert wind direction from degrees to flow direction emoji. """Convert wind direction from degrees to flow direction emoji.
Args: Args:
@ -30,7 +32,7 @@ class WeatherFetcher:
index = round(degrees / 45) % 8 index = round(degrees / 45) % 8
return directions[index] return directions[index]
def get_wind_speed_indicator(self, wind_speed): def get_wind_speed_indicator(self, wind_speed: Optional[float]) -> str:
"""Get wind speed level indicator based on speed. """Get wind speed level indicator based on speed.
Args: Args:
@ -51,7 +53,7 @@ class WeatherFetcher:
else: else:
return '🔴' # Dangerous return '🔴' # Dangerous
def get_uv_index_indicator(self, uv_index): def get_uv_index_indicator(self, uv_index: Optional[float]) -> str:
"""Get UV index level indicator based on index. """Get UV index level indicator based on index.
Args: Args:
@ -74,7 +76,7 @@ class WeatherFetcher:
else: else:
return '🟣' # Extreme return '🟣' # Extreme
def get_weather(self, latitude, longitude): def get_weather(self, latitude: float, longitude: float) -> Optional[dict]:
"""Fetch weather data for given coordinates. """Fetch weather data for given coordinates.
Args: Args:

View File

@ -1,6 +1,6 @@
[project] [project]
name = "hiking-assistant" name = "hiking-assistant"
version = "1.0.0" version = "1.0.2"
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"
@ -13,7 +13,6 @@ dependencies = [
"requests>=2.32.3", "requests>=2.32.3",
"geopy>=2.4.1", "geopy>=2.4.1",
"streamlit-plotly-events>=0.0.6", "streamlit-plotly-events>=0.0.6",
"pyyaml>=6.0.3",
] ]
[dependency-groups] [dependency-groups]

4
uv.lock generated
View File

@ -227,14 +227,13 @@ wheels = [
[[package]] [[package]]
name = "hiking-assistant" name = "hiking-assistant"
version = "1.0.0" version = "1.0.2"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "folium" }, { name = "folium" },
{ name = "geopy" }, { name = "geopy" },
{ name = "gpxpy" }, { name = "gpxpy" },
{ name = "plotly" }, { name = "plotly" },
{ name = "pyyaml" },
{ name = "requests" }, { name = "requests" },
{ name = "streamlit" }, { name = "streamlit" },
{ name = "streamlit-folium" }, { name = "streamlit-folium" },
@ -254,7 +253,6 @@ requires-dist = [
{ name = "geopy", specifier = ">=2.4.1" }, { name = "geopy", specifier = ">=2.4.1" },
{ name = "gpxpy", specifier = ">=1.6.2" }, { name = "gpxpy", specifier = ">=1.6.2" },
{ name = "plotly", specifier = ">=5.24.1" }, { name = "plotly", specifier = ">=5.24.1" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "streamlit", specifier = ">=1.51.0" }, { name = "streamlit", specifier = ">=1.51.0" },
{ name = "streamlit-folium", specifier = ">=0.23.1" }, { name = "streamlit-folium", specifier = ">=0.23.1" },